@memberjunction/server 6.2.0-edge.2 → 6.2.0-edge.4

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 (435) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/BridgeHostInstance.d.ts +25 -0
  4. package/dist/agentSessions/BridgeHostInstance.d.ts.map +1 -0
  5. package/dist/agentSessions/BridgeHostInstance.js +30 -0
  6. package/dist/agentSessions/BridgeHostInstance.js.map +1 -0
  7. package/dist/agentSessions/HostInstance.d.ts +75 -11
  8. package/dist/agentSessions/HostInstance.d.ts.map +1 -1
  9. package/dist/agentSessions/HostInstance.js +99 -28
  10. package/dist/agentSessions/HostInstance.js.map +1 -1
  11. package/dist/agentSessions/ReturningVisitorRecap.d.ts.map +1 -1
  12. package/dist/agentSessions/ReturningVisitorRecap.js +7 -3
  13. package/dist/agentSessions/ReturningVisitorRecap.js.map +1 -1
  14. package/dist/agentSessions/SessionJanitor.d.ts +173 -25
  15. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  16. package/dist/agentSessions/SessionJanitor.js +452 -54
  17. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  18. package/dist/agentSessions/SessionManager.d.ts +43 -2
  19. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  20. package/dist/agentSessions/SessionManager.js +99 -8
  21. package/dist/agentSessions/SessionManager.js.map +1 -1
  22. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  23. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  24. package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
  25. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  26. package/dist/agentSessions/index.d.ts +2 -0
  27. package/dist/agentSessions/index.d.ts.map +1 -1
  28. package/dist/agentSessions/index.js +10 -3
  29. package/dist/agentSessions/index.js.map +1 -1
  30. package/dist/auth/actionAuthorization.d.ts +40 -0
  31. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  32. package/dist/auth/actionAuthorization.js +94 -0
  33. package/dist/auth/actionAuthorization.js.map +1 -0
  34. package/dist/auth/index.d.ts.map +1 -1
  35. package/dist/auth/index.js +3 -10
  36. package/dist/auth/index.js.map +1 -1
  37. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  38. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  39. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  40. package/dist/auth/newUsers.d.ts +10 -0
  41. package/dist/auth/newUsers.d.ts.map +1 -1
  42. package/dist/auth/newUsers.js +80 -11
  43. package/dist/auth/newUsers.js.map +1 -1
  44. package/dist/config.d.ts +4567 -71
  45. package/dist/config.d.ts.map +1 -1
  46. package/dist/config.js +246 -3
  47. package/dist/config.js.map +1 -1
  48. package/dist/context.d.ts +8 -0
  49. package/dist/context.d.ts.map +1 -1
  50. package/dist/context.js +25 -34
  51. package/dist/context.js.map +1 -1
  52. package/dist/generated/graphql-schemas/__mj.d.ts +1073 -10
  53. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  54. package/dist/generated/graphql-schemas/__mj.js +9188 -3649
  55. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  56. package/dist/generic/OrphanedConversationDetailReconciler.js +3 -3
  57. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -1
  58. package/dist/generic/ResolverBase.d.ts +62 -6
  59. package/dist/generic/ResolverBase.d.ts.map +1 -1
  60. package/dist/generic/ResolverBase.js +179 -35
  61. package/dist/generic/ResolverBase.js.map +1 -1
  62. package/dist/generic/RunViewResolver.d.ts +4 -0
  63. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  64. package/dist/generic/RunViewResolver.js +37 -1
  65. package/dist/generic/RunViewResolver.js.map +1 -1
  66. package/dist/index.d.ts +7 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +214 -159
  69. package/dist/index.js.map +1 -1
  70. package/dist/isolatedProvider.d.ts +20 -0
  71. package/dist/isolatedProvider.d.ts.map +1 -0
  72. package/dist/isolatedProvider.js +22 -0
  73. package/dist/isolatedProvider.js.map +1 -0
  74. package/dist/postgresqlCompat.d.ts +8 -0
  75. package/dist/postgresqlCompat.d.ts.map +1 -0
  76. package/dist/postgresqlCompat.js +19 -0
  77. package/dist/postgresqlCompat.js.map +1 -0
  78. package/dist/postgresqlPoolSettings.d.ts +108 -0
  79. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  80. package/dist/postgresqlPoolSettings.js +134 -0
  81. package/dist/postgresqlPoolSettings.js.map +1 -0
  82. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +44 -4
  83. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  84. package/dist/realtimeProxy/RealtimeProxyServer.js +121 -16
  85. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  86. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts +171 -0
  87. package/dist/realtimeProxy/RealtimeRelayTunnel.d.ts.map +1 -0
  88. package/dist/realtimeProxy/RealtimeRelayTunnel.js +532 -0
  89. package/dist/realtimeProxy/RealtimeRelayTunnel.js.map +1 -0
  90. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts +148 -0
  91. package/dist/realtimeSessions/RealtimeSessionEventService.d.ts.map +1 -0
  92. package/dist/realtimeSessions/RealtimeSessionEventService.js +191 -0
  93. package/dist/realtimeSessions/RealtimeSessionEventService.js.map +1 -0
  94. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts +66 -0
  95. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -0
  96. package/dist/realtimeSessions/RealtimeSessionVerificationService.js +138 -0
  97. package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -0
  98. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts +38 -0
  99. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -0
  100. package/dist/realtimeSessions/RealtimeVerifyRouter.js +98 -0
  101. package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -0
  102. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts +61 -0
  103. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts.map +1 -0
  104. package/dist/realtimeSessions/SessionVerificationPortsBase.js +149 -0
  105. package/dist/realtimeSessions/SessionVerificationPortsBase.js.map +1 -0
  106. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts +57 -0
  107. package/dist/realtimeSessions/SlidingWindowRateLimiter.d.ts.map +1 -0
  108. package/dist/realtimeSessions/SlidingWindowRateLimiter.js +69 -0
  109. package/dist/realtimeSessions/SlidingWindowRateLimiter.js.map +1 -0
  110. package/dist/realtimeSessions/index.d.ts +17 -0
  111. package/dist/realtimeSessions/index.d.ts.map +1 -0
  112. package/dist/realtimeSessions/index.js +17 -0
  113. package/dist/realtimeSessions/index.js.map +1 -0
  114. package/dist/realtimeSessions/sessionAccess.d.ts +86 -0
  115. package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -0
  116. package/dist/realtimeSessions/sessionAccess.js +90 -0
  117. package/dist/realtimeSessions/sessionAccess.js.map +1 -0
  118. package/dist/realtimeSessions/sessionConfigStamps.d.ts +70 -0
  119. package/dist/realtimeSessions/sessionConfigStamps.d.ts.map +1 -0
  120. package/dist/realtimeSessions/sessionConfigStamps.js +93 -0
  121. package/dist/realtimeSessions/sessionConfigStamps.js.map +1 -0
  122. package/dist/realtimeSessions/subscriptionAuthorization.d.ts +65 -0
  123. package/dist/realtimeSessions/subscriptionAuthorization.d.ts.map +1 -0
  124. package/dist/realtimeSessions/subscriptionAuthorization.js +71 -0
  125. package/dist/realtimeSessions/subscriptionAuthorization.js.map +1 -0
  126. package/dist/realtimeSessions/verificationCore.d.ts +243 -0
  127. package/dist/realtimeSessions/verificationCore.d.ts.map +1 -0
  128. package/dist/realtimeSessions/verificationCore.js +356 -0
  129. package/dist/realtimeSessions/verificationCore.js.map +1 -0
  130. package/dist/realtimeSessions/verificationPages.d.ts +66 -0
  131. package/dist/realtimeSessions/verificationPages.d.ts.map +1 -0
  132. package/dist/realtimeSessions/verificationPages.js +110 -0
  133. package/dist/realtimeSessions/verificationPages.js.map +1 -0
  134. package/dist/realtimeSessions/verificationPolicy.d.ts +147 -0
  135. package/dist/realtimeSessions/verificationPolicy.d.ts.map +1 -0
  136. package/dist/realtimeSessions/verificationPolicy.js +249 -0
  137. package/dist/realtimeSessions/verificationPolicy.js.map +1 -0
  138. package/dist/realtimeSessions/verificationWorkflow.d.ts +263 -0
  139. package/dist/realtimeSessions/verificationWorkflow.d.ts.map +1 -0
  140. package/dist/realtimeSessions/verificationWorkflow.js +435 -0
  141. package/dist/realtimeSessions/verificationWorkflow.js.map +1 -0
  142. package/dist/realtimeWidget/widgetGuestElevation.d.ts +20 -2
  143. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  144. package/dist/realtimeWidget/widgetGuestElevation.js +31 -2
  145. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  146. package/dist/resolvers/ActionResolver.d.ts +31 -5
  147. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  148. package/dist/resolvers/ActionResolver.js +75 -14
  149. package/dist/resolvers/ActionResolver.js.map +1 -1
  150. package/dist/resolvers/AdhocQueryResolver.d.ts +35 -17
  151. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  152. package/dist/resolvers/AdhocQueryResolver.js +92 -166
  153. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  154. package/dist/resolvers/CacheStatsResolver.d.ts +27 -0
  155. package/dist/resolvers/CacheStatsResolver.d.ts.map +1 -1
  156. package/dist/resolvers/CacheStatsResolver.js +113 -2
  157. package/dist/resolvers/CacheStatsResolver.js.map +1 -1
  158. package/dist/resolvers/ClientToolRequestResolver.d.ts +4 -2
  159. package/dist/resolvers/ClientToolRequestResolver.d.ts.map +1 -1
  160. package/dist/resolvers/ClientToolRequestResolver.js +28 -10
  161. package/dist/resolvers/ClientToolRequestResolver.js.map +1 -1
  162. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  163. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  164. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  165. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  166. package/dist/resolvers/FetchEntityVectorsResolver.d.ts +12 -1
  167. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  168. package/dist/resolvers/FetchEntityVectorsResolver.js +83 -2
  169. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  170. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  171. package/dist/resolvers/GetDataResolver.js +9 -0
  172. package/dist/resolvers/GetDataResolver.js.map +1 -1
  173. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  174. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  175. package/dist/resolvers/HumanHandoffResolver.js +330 -0
  176. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  177. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  178. package/dist/resolvers/MCPResolver.js +9 -4
  179. package/dist/resolvers/MCPResolver.js.map +1 -1
  180. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  181. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  182. package/dist/resolvers/MeetingResolver.js +381 -0
  183. package/dist/resolvers/MeetingResolver.js.map +1 -0
  184. package/dist/resolvers/RealtimeBridgeResolver.d.ts +88 -3
  185. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  186. package/dist/resolvers/RealtimeBridgeResolver.js +352 -12
  187. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  188. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +156 -108
  189. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  190. package/dist/resolvers/RealtimeClientSessionResolver.js +395 -293
  191. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  192. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts +61 -0
  193. package/dist/resolvers/RealtimeSessionEventsResolver.d.ts.map +1 -0
  194. package/dist/resolvers/RealtimeSessionEventsResolver.js +130 -0
  195. package/dist/resolvers/RealtimeSessionEventsResolver.js.map +1 -0
  196. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts +85 -0
  197. package/dist/resolvers/RealtimeSessionVerificationResolver.d.ts.map +1 -0
  198. package/dist/resolvers/RealtimeSessionVerificationResolver.js +188 -0
  199. package/dist/resolvers/RealtimeSessionVerificationResolver.js.map +1 -0
  200. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  201. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  202. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  203. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  204. package/dist/resolvers/RunAIAgentResolver.d.ts +10 -0
  205. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  206. package/dist/resolvers/RunAIAgentResolver.js +68 -11
  207. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  208. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  209. package/dist/resolvers/RunTemplateResolver.js +12 -11
  210. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  211. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  212. package/dist/resolvers/SyncDataResolver.js +6 -2
  213. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  214. package/dist/resolvers/SyncRolesUsersResolver.d.ts +2 -0
  215. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  216. package/dist/resolvers/SyncRolesUsersResolver.js +23 -6
  217. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  218. package/dist/resolvers/UserAvatarResolver.d.ts +16 -0
  219. package/dist/resolvers/UserAvatarResolver.d.ts.map +1 -0
  220. package/dist/resolvers/UserAvatarResolver.js +124 -0
  221. package/dist/resolvers/UserAvatarResolver.js.map +1 -0
  222. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  223. package/dist/resolvers/UserResolver.js +2 -17
  224. package/dist/resolvers/UserResolver.js.map +1 -1
  225. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  226. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  227. package/dist/resolvers/adhocTimeout.js +12 -0
  228. package/dist/resolvers/adhocTimeout.js.map +1 -0
  229. package/dist/resolvers/agentVisionConsentAudit.d.ts +37 -0
  230. package/dist/resolvers/agentVisionConsentAudit.d.ts.map +1 -0
  231. package/dist/resolvers/agentVisionConsentAudit.js +63 -0
  232. package/dist/resolvers/agentVisionConsentAudit.js.map +1 -0
  233. package/dist/resolvers/avatarInputValidation.d.ts +40 -0
  234. package/dist/resolvers/avatarInputValidation.d.ts.map +1 -0
  235. package/dist/resolvers/avatarInputValidation.js +97 -0
  236. package/dist/resolvers/avatarInputValidation.js.map +1 -0
  237. package/dist/resolvers/currentUserRoles.d.ts +29 -0
  238. package/dist/resolvers/currentUserRoles.d.ts.map +1 -1
  239. package/dist/resolvers/currentUserRoles.js +30 -0
  240. package/dist/resolvers/currentUserRoles.js.map +1 -1
  241. package/dist/resolvers/meetings/index.d.ts +5 -0
  242. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  243. package/dist/resolvers/meetings/index.js +5 -0
  244. package/dist/resolvers/meetings/index.js.map +1 -0
  245. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  246. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  247. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  248. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  249. package/dist/resolvers/meetings/meetingMutations.d.ts +18 -0
  250. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  251. package/dist/resolvers/meetings/meetingMutations.js +439 -0
  252. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  253. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  254. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  255. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  256. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  257. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  258. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  259. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  260. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  261. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  262. package/dist/rest/ViewOperationsHandler.js +8 -5
  263. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  264. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  265. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  266. package/dist/services/TaskGraphActionRunner.js +27 -1
  267. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  268. package/dist/services/TaskGraphContinuationDeliverer.d.ts +4 -3
  269. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  270. package/dist/services/TaskGraphContinuationDeliverer.js +13 -7
  271. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  272. package/dist/services/WorkQueueHostService.d.ts +36 -0
  273. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  274. package/dist/services/WorkQueueHostService.js +97 -0
  275. package/dist/services/WorkQueueHostService.js.map +1 -0
  276. package/dist/services/workQueueConfig.d.ts +103 -0
  277. package/dist/services/workQueueConfig.d.ts.map +1 -0
  278. package/dist/services/workQueueConfig.js +48 -0
  279. package/dist/services/workQueueConfig.js.map +1 -0
  280. package/dist/sharedCache.d.ts +105 -0
  281. package/dist/sharedCache.d.ts.map +1 -0
  282. package/dist/sharedCache.js +276 -0
  283. package/dist/sharedCache.js.map +1 -0
  284. package/dist/types.d.ts +11 -0
  285. package/dist/types.d.ts.map +1 -1
  286. package/dist/types.js.map +1 -1
  287. package/package.json +97 -94
  288. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  289. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  290. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  291. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  292. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  293. package/src/__tests__/BridgeHostInstance.test.ts +232 -0
  294. package/src/__tests__/ClientToolRequestResolver.test.ts +116 -0
  295. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  296. package/src/__tests__/FetchEntityVectorsResolver.test.ts +285 -0
  297. package/src/__tests__/HostInstance.test.ts +84 -0
  298. package/src/__tests__/HumanHandoffResolver.test.ts +250 -0
  299. package/src/__tests__/MeetingResolver.test.ts +448 -0
  300. package/src/__tests__/RealtimeBridgeResolver.test.ts +651 -4
  301. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  302. package/src/__tests__/RealtimeClientSessionResolver.test.ts +682 -58
  303. package/src/__tests__/RealtimeProxyServer.test.ts +16 -1
  304. package/src/__tests__/RealtimeRelay.test.ts +876 -0
  305. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  306. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  307. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  308. package/src/__tests__/ResolverBase.clientOldValues.test.ts +6 -3
  309. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  310. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  311. package/src/__tests__/ReturningVisitorRecap.test.ts +115 -0
  312. package/src/__tests__/RunAIAgentResolver.branch.test.ts +169 -0
  313. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +4 -4
  314. package/src/__tests__/RunAIAgentResolver.stoppedPredecessor.test.ts +166 -0
  315. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  316. package/src/__tests__/RunViewResolver.cacheCheckForwarding.test.ts +63 -0
  317. package/src/__tests__/SessionJanitor.test.ts +845 -18
  318. package/src/__tests__/SessionManager.test.ts +213 -17
  319. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  320. package/src/__tests__/SyncRolesUsersResolver.userRoles.test.ts +97 -0
  321. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
  322. package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +50 -3
  323. package/src/__tests__/UserAvatarResolver.test.ts +255 -0
  324. package/src/__tests__/UserResolver.currentUser.test.ts +16 -1
  325. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  326. package/src/__tests__/actionAuthorization.test.ts +130 -0
  327. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  328. package/src/__tests__/agentVisionConsentAudit.test.ts +118 -0
  329. package/src/__tests__/avatarInputValidation.test.ts +160 -0
  330. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  331. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  332. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  333. package/src/__tests__/newUsers.template.test.ts +151 -0
  334. package/src/__tests__/newUsers.test.ts +74 -5
  335. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +17 -2
  336. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  337. package/src/__tests__/realtimeSessions/RealtimeSessionEventService.test.ts +201 -0
  338. package/src/__tests__/realtimeSessions/RealtimeSessionEventsResolver.test.ts +215 -0
  339. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationResolver.test.ts +102 -0
  340. package/src/__tests__/realtimeSessions/RealtimeSessionVerificationService.test.ts +349 -0
  341. package/src/__tests__/realtimeSessions/RealtimeVerifyRouter.test.ts +131 -0
  342. package/src/__tests__/realtimeSessions/SlidingWindowRateLimiter.test.ts +56 -0
  343. package/src/__tests__/realtimeSessions/helpers.ts +4 -0
  344. package/src/__tests__/realtimeSessions/sessionAccess.test.ts +129 -0
  345. package/src/__tests__/realtimeSessions/sessionConfigStamps.test.ts +124 -0
  346. package/src/__tests__/realtimeSessions/verificationCore.test.ts +355 -0
  347. package/src/__tests__/realtimeSessions/verificationPages.test.ts +84 -0
  348. package/src/__tests__/realtimeSessions/verificationPolicy.test.ts +226 -0
  349. package/src/__tests__/realtimeSessions/verificationWorkflow.test.ts +633 -0
  350. package/src/__tests__/resolverBase.fls.test.ts +87 -4
  351. package/src/__tests__/serverExtensionReservedRoots.test.ts +2 -0
  352. package/src/__tests__/sharedCache.test.ts +310 -0
  353. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  354. package/src/__tests__/widgetGuestElevation.test.ts +44 -0
  355. package/src/agentSessions/BridgeHostInstance.ts +32 -0
  356. package/src/agentSessions/HostInstance.ts +124 -28
  357. package/src/agentSessions/ReturningVisitorRecap.ts +7 -3
  358. package/src/agentSessions/SessionJanitor.ts +543 -57
  359. package/src/agentSessions/SessionManager.ts +125 -25
  360. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  361. package/src/agentSessions/index.ts +10 -3
  362. package/src/auth/actionAuthorization.ts +116 -0
  363. package/src/auth/index.ts +3 -8
  364. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  365. package/src/auth/newUsers.ts +99 -11
  366. package/src/config.ts +255 -3
  367. package/src/context.ts +26 -34
  368. package/src/generated/graphql-schemas/__mj.ts +22701 -18838
  369. package/src/generic/OrphanedConversationDetailReconciler.ts +3 -3
  370. package/src/generic/ResolverBase.ts +210 -36
  371. package/src/generic/RunViewResolver.ts +37 -2
  372. package/src/index.ts +236 -170
  373. package/src/isolatedProvider.ts +22 -0
  374. package/src/postgresqlCompat.ts +21 -0
  375. package/src/postgresqlPoolSettings.ts +213 -0
  376. package/src/realtimeProxy/RealtimeProxyServer.ts +148 -16
  377. package/src/realtimeProxy/RealtimeRelayTunnel.ts +645 -0
  378. package/src/realtimeSessions/README.md +179 -0
  379. package/src/realtimeSessions/RealtimeSessionEventService.ts +274 -0
  380. package/src/realtimeSessions/RealtimeSessionVerificationService.ts +167 -0
  381. package/src/realtimeSessions/RealtimeVerifyRouter.ts +108 -0
  382. package/src/realtimeSessions/SessionVerificationPortsBase.ts +189 -0
  383. package/src/realtimeSessions/SlidingWindowRateLimiter.ts +92 -0
  384. package/src/realtimeSessions/index.ts +16 -0
  385. package/src/realtimeSessions/sessionAccess.ts +141 -0
  386. package/src/realtimeSessions/sessionConfigStamps.ts +123 -0
  387. package/src/realtimeSessions/subscriptionAuthorization.ts +116 -0
  388. package/src/realtimeSessions/verificationCore.ts +502 -0
  389. package/src/realtimeSessions/verificationPages.ts +149 -0
  390. package/src/realtimeSessions/verificationPolicy.ts +321 -0
  391. package/src/realtimeSessions/verificationWorkflow.ts +702 -0
  392. package/src/realtimeWidget/widgetGuestElevation.ts +34 -2
  393. package/src/resolvers/ActionResolver.ts +84 -21
  394. package/src/resolvers/AdhocQueryResolver.ts +105 -187
  395. package/src/resolvers/CacheStatsResolver.ts +89 -2
  396. package/src/resolvers/ClientToolRequestResolver.ts +25 -7
  397. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  398. package/src/resolvers/FetchEntityVectorsResolver.ts +97 -2
  399. package/src/resolvers/GetDataResolver.ts +10 -0
  400. package/src/resolvers/HumanHandoffResolver.ts +312 -0
  401. package/src/resolvers/MCPResolver.ts +8 -5
  402. package/src/resolvers/MeetingResolver.ts +438 -0
  403. package/src/resolvers/RealtimeBridgeResolver.ts +385 -19
  404. package/src/resolvers/RealtimeClientSessionResolver.ts +503 -344
  405. package/src/resolvers/RealtimeSessionEventsResolver.ts +133 -0
  406. package/src/resolvers/RealtimeSessionVerificationResolver.ts +174 -0
  407. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  408. package/src/resolvers/RunAIAgentResolver.ts +78 -11
  409. package/src/resolvers/RunTemplateResolver.ts +13 -12
  410. package/src/resolvers/SyncDataResolver.ts +6 -2
  411. package/src/resolvers/SyncRolesUsersResolver.ts +24 -6
  412. package/src/resolvers/UserAvatarResolver.ts +111 -0
  413. package/src/resolvers/UserResolver.ts +2 -16
  414. package/src/resolvers/adhocTimeout.ts +12 -0
  415. package/src/resolvers/agentVisionConsentAudit.ts +82 -0
  416. package/src/resolvers/avatarInputValidation.ts +123 -0
  417. package/src/resolvers/currentUserRoles.ts +32 -1
  418. package/src/resolvers/meetings/index.ts +4 -0
  419. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  420. package/src/resolvers/meetings/meetingMutations.ts +556 -0
  421. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  422. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  423. package/src/rest/ViewOperationsHandler.ts +8 -5
  424. package/src/services/TaskGraphActionRunner.ts +29 -1
  425. package/src/services/TaskGraphContinuationDeliverer.ts +13 -7
  426. package/src/services/WorkQueueHostService.ts +129 -0
  427. package/src/services/workQueueConfig.ts +52 -0
  428. package/src/sharedCache.ts +302 -0
  429. package/src/types.ts +12 -0
  430. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  431. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  432. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  433. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  434. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  435. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -11,9 +11,9 @@
11
11
  * and **executes** every tool call the browser relays back. The three mutations here cover the MVP:
12
12
  *
13
13
  * 1. {@link RealtimeClientSessionResolver.StartRealtimeClientSession} — authorize, create the
14
- * session (storing the target agent id server-side, authoritatively), mint the ephemeral config.
15
- * 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call,
16
- * reading the target agent id from the session (NOT the client) so the browser can't swap targets.
14
+ * session (storing the target agent id on it), mint the ephemeral config.
15
+ * 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call
16
+ * for the target agent stored on the session, re-authorized for the caller on every call.
17
17
  * 3. {@link RealtimeClientSessionResolver.RelayRealtimeTranscript} — persist a transcript turn as a
18
18
  * `Conversation Detail`.
19
19
  *
@@ -21,12 +21,19 @@
21
21
  * - **Start**: the caller must have `CanRun` on the *target* agent (the Realtime Co-Agent is an internal
22
22
  * orchestration agent, so the meaningful gate is the agent doing the real work).
23
23
  * - **Execute / Relay**: inbound ownership — the session's `UserID` must equal `contextUser.ID`.
24
+ * - **Session `Config`**: the owner can edit it through the generic entity API, so nothing in it is
25
+ * trusted. Execute re-checks `CanRun` on its target, direct actions never come from it, and its
26
+ * run ids are used only when {@link SessionRunIDVerifier} finds them to be records of the session.
27
+ * - **Scope-limited callers** (anonymous and resource-scoped magic-link sessions, including public
28
+ * web-widget guests): no direct actions. Their scope exists only as row-level filters, which an
29
+ * action can read around, and a scoped anonymous caller's relay runs as the system user. They
30
+ * reach the target agent only through `invoke-target-agent`.
24
31
  *
25
32
  * @module @memberjunction/server
26
33
  */
27
34
  import { Resolver, Mutation, Arg, Ctx, Int, Float, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
28
35
  import { AppContext, UserPayload } from '../types.js';
29
- import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView, BaseEntity } from '@memberjunction/core';
36
+ import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogErrorEx, LogStatus, RunView, BaseEntity } from '@memberjunction/core';
30
37
  import { UUIDsEqual, IsValidUUID } from '@memberjunction/global';
31
38
  import {
32
39
  MJAIAgentEntity,
@@ -36,9 +43,10 @@ import {
36
43
  MJArtifactVersionEntity,
37
44
  MJConversationDetailArtifactEntity,
38
45
  MJConversationDetailEntity,
46
+ ConversationEngine,
39
47
  } from '@memberjunction/core-entities';
40
48
  import { AIEngine } from '@memberjunction/aiengine';
41
- import { AIAgentPermissionHelper, AIEngineBase } from '@memberjunction/ai-engine-base';
49
+ import { AIAgentPermissionHelper, AIEngineBase, HasRealtimeUsage, ParseRealtimeUsageRecord, type RealtimeUsageRecord } from '@memberjunction/ai-engine-base';
42
50
  import {
43
51
  RealtimeClientSessionService,
44
52
  DelegatedRunArtifact,
@@ -48,6 +56,9 @@ import {
48
56
  FindIgnoredRealtimeConfigKeys,
49
57
  ParseRealtimeTypeConfiguration,
50
58
  ResolveEffectiveRealtimeConfig,
59
+ ResolveRealtimeCoAgentID,
60
+ FilterAllowedAgentsByCanRun,
61
+ INVOKE_TARGET_AGENT_TOOL_NAME,
51
62
  RealtimeAllowedAgent,
52
63
  RealtimeDirectActionsConfig,
53
64
  REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION,
@@ -56,13 +67,26 @@ import {
56
67
  writeRealtimeRecordingSegment,
57
68
  deleteRealtimeRecordingSegments,
58
69
  } from '@memberjunction/ai-agents';
59
- import { AgentExecutionProgressCallback, MJAIAgentEntityExtended, AppContextSnapshot } from '@memberjunction/ai-core-plus';
70
+ import {
71
+ AgentExecutionProgressCallback,
72
+ AppContextSnapshot,
73
+ IsValidRealtimeToolDefinition,
74
+ NormalizeChannelKey,
75
+ ParseRealtimeChannelCandidates,
76
+ type RealtimeChannelCandidate,
77
+ type RealtimeSessionClientPolicy
78
+ } from '@memberjunction/ai-core-plus';
60
79
  import { ChatMessage, RealtimeToolDefinition } from '@memberjunction/ai';
61
80
  import { ResolverBase } from '../generic/ResolverBase.js';
62
81
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
63
82
  import { GetReadWriteProvider } from '../util.js';
64
83
  import { SessionManager } from '../agentSessions/index.js';
65
- import { ResolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
84
+ import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
85
+ import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
86
+ import { ResolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser, ResolveRecordingStoreUser } from '../realtimeWidget/widgetGuestElevation.js';
87
+ import { RunWithTrustedSessionConfigWrites } from '@memberjunction/core-entities-server';
88
+ import { BuildMintConfigStamps, LoadInheritedVerifiedIdentity } from '../realtimeSessions/sessionConfigStamps.js';
89
+ import { ParseConfigObject } from '../realtimeSessions/verificationCore.js';
66
90
 
67
91
  /**
68
92
  * Progress steps worth narrating to the realtime model — mirrors the normal agent-run path's filter
@@ -72,30 +96,10 @@ import { ResolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../
72
96
  const SIGNIFICANT_PROGRESS_STEPS = ['prompt_execution', 'action_execution', 'subagent_execution', 'decision_processing'];
73
97
 
74
98
  /**
75
- * The seeded name of the internal orchestration agent that fronts a target agent in realtime
76
- * sessions (voice + interactive channels). This is the GLOBAL DEFAULT co-agent — the final step of
77
- * the co-agent resolution chain (see {@link RealtimeClientSessionResolver.resolveCoAgentID}).
78
- * Deployments can override it per agent (`AIAgent.DefaultCoAgentID`), per agent type (an
79
- * `AIAgentCoAgent` row with `TargetAgentTypeID` + `IsDefault`), or per call (the `coAgentId`
80
- * mutation argument) without
81
- * touching this seed.
82
- */
83
- const REALTIME_CO_AGENT_NAME = 'Realtime Co-Agent';
84
-
85
- /**
86
- * DEPRECATED legacy seed name of {@link REALTIME_CO_AGENT_NAME}, from before the agent's rename
87
- * from "Voice Co-Agent" to "Realtime Co-Agent". Deployments that have not re-synced the agent seed
88
- * still carry this name, so {@link RealtimeClientSessionResolver.resolveGlobalCoAgentID} falls
89
- * back to it (with a deprecation log) when no agent named {@link REALTIME_CO_AGENT_NAME} exists.
99
+ * The longest `usageDetailsJson` a usage relay may carry. Every field of both detail blocks fits in a few hundred
100
+ * characters, so anything longer is not a relay the browser runtime sends.
90
101
  */
91
- const LEGACY_REALTIME_CO_AGENT_NAME = 'Voice Co-Agent';
92
-
93
- /**
94
- * The seeded name of the Realtime agent TYPE. Every co-agent candidate (explicit or metadata
95
- * default) must be an Active agent of this type — a co-agent drives a streaming full-duplex
96
- * realtime session, which only `RealtimeAgentType`-driven agents support.
97
- */
98
- const REALTIME_AGENT_TYPE_NAME = 'Realtime';
102
+ const MAX_USAGE_DETAILS_JSON_LENGTH = 4096;
99
103
 
100
104
  /** Entity name — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
101
105
  const SESSION_ENTITY = 'MJ: AI Agent Sessions';
@@ -124,7 +128,8 @@ const WHITEBOARD_ARTIFACT_TYPE_NAME = 'Whiteboard';
124
128
  */
125
129
  const MAX_CLIENT_TOOLS = 64;
126
130
  /** Maximum accepted size (chars) of the serialized client tool declarations. Raised in step with
127
- * {@link MAX_CLIENT_TOOLS} — multi-channel tool sets with verbose descriptions + JSON schemas run large. */
131
+ * {@link MAX_CLIENT_TOOLS} — multi-channel tool sets with verbose descriptions + JSON schemas run large.
132
+ * Also the largest serialized app-context snapshot stored on the session config. */
128
133
  const MAX_CLIENT_TOOLS_JSON_CHARS = 256_000;
129
134
  /** Maximum accepted size (chars) of a persisted channel state blob. */
130
135
  const MAX_CHANNEL_STATE_CHARS = 2_000_000;
@@ -142,9 +147,9 @@ const MAX_CONVERSATION_HISTORY_TURNS = 30;
142
147
  const MAX_CONVERSATION_HISTORY_CHARS = 8_000;
143
148
 
144
149
  /**
145
- * Authoritative shape persisted in `AIAgentSession.Config_` for a client-direct voice session.
146
- * The target agent id is stored here at start and read back on every relay — the browser never
147
- * re-supplies it, so it cannot swap targets mid-session.
150
+ * Shape persisted in `AIAgentSession.Config_` for a client-direct voice session. The server writes
151
+ * it, but the session owner can also edit it through the generic entity API, so relays re-check
152
+ * what they read from it (see the authorization model above).
148
153
  */
149
154
  interface RealtimeSessionConfig {
150
155
  /** The top-level target agent the co-agent voices on behalf of. */
@@ -181,6 +186,12 @@ interface RealtimeSessionConfig {
181
186
  * for observability / continuity; absent when the session carries no app context.
182
187
  */
183
188
  applicationID?: string;
189
+ /**
190
+ * The app-context snapshot the browser sent at session start. Passed into each delegated
191
+ * target-agent run so its prompt sees the surface context and tools. Absent when the start carried
192
+ * none, or when its JSON is longer than {@link MAX_CLIENT_TOOLS_JSON_CHARS}.
193
+ */
194
+ appContext?: AppContextSnapshot;
184
195
  /**
185
196
  * The session's effective allowed delegation targets (union from the config cascade, incl. the app's
186
197
  * `RelevantAgents`). Persisted at start so each relayed `invoke-target-agent` call can validate a
@@ -199,6 +210,24 @@ interface RealtimeSessionConfig {
199
210
  * agent default kit is used (the common case).
200
211
  */
201
212
  mediaCollectionID?: string;
213
+ /**
214
+ * The fork (`MJ: Conversation Branches.ID`) the session was started on, fixed at start; absent means
215
+ * Main. Every transcript, direct-action and anchor row the session writes carries it.
216
+ */
217
+ conversationBranchID?: string;
218
+ /**
219
+ * Server-authoritative identity-verification state (policy snapshot taken at mint, pending/verified
220
+ * state written by `RealtimeSessionVerificationService`). Protected from owner writes by
221
+ * `MJAIAgentSessionEntityServer`; opaque to this resolver beyond preserving it across config rewrites.
222
+ */
223
+ identityVerification?: Record<string, unknown>;
224
+ /**
225
+ * The keys of the interactive channels the session's resolved scope put IN the session (registry
226
+ * kill switch, agent and app configuration applied). Persisted at start so a later server-side
227
+ * call naming a channel — a state or artifact save — can check it is actually in scope. Absent
228
+ * (an unscoped session: a client that reported no channel candidates) ⇒ no channel check.
229
+ */
230
+ channels?: string[];
202
231
  }
203
232
 
204
233
  /**
@@ -223,14 +252,32 @@ export class StartRealtimeClientSessionResult {
223
252
  @Field(() => String)
224
253
  Model: string;
225
254
 
226
- /** The short-lived client secret the browser presents to the provider to authenticate. */
255
+ /**
256
+ * The short-lived client secret the browser presents to the provider to authenticate. Empty on a relay session
257
+ * ({@link StartRealtimeClientSessionResult.Transport} `relay`), whose {@link StartRealtimeClientSessionResult.RelayUrl}
258
+ * carries the relay's ticket instead.
259
+ */
227
260
  @Field(() => String)
228
261
  EphemeralToken: string;
229
262
 
230
- /** ISO-8601 timestamp at which {@link StartRealtimeClientSessionResult.EphemeralToken} expires. */
263
+ /** ISO-8601 timestamp at which {@link StartRealtimeClientSessionResult.EphemeralToken} (or a relay session's ticket) expires. */
231
264
  @Field(() => String)
232
265
  ExpiresAt: string;
233
266
 
267
+ /**
268
+ * How the browser reaches the provider: `direct` (with the ephemeral token) or `relay` (through MJAPI's realtime
269
+ * relay at {@link StartRealtimeClientSessionResult.RelayUrl}). Null means direct.
270
+ */
271
+ @Field(() => String, { nullable: true })
272
+ Transport?: string;
273
+
274
+ /**
275
+ * Where a relay session's browser connects (MJAPI's realtime relay). Null on a direct session. The URL carries the
276
+ * session's ticket, so it is a credential and is never logged.
277
+ */
278
+ @Field(() => String, { nullable: true })
279
+ RelayUrl?: string;
280
+
234
281
  /** JSON string of the provider-native session config the browser applies verbatim. */
235
282
  @Field(() => String)
236
283
  SessionConfigJson: string;
@@ -287,6 +334,25 @@ export class StartRealtimeClientSessionResult {
287
334
  */
288
335
  @Field(() => String, { nullable: true })
289
336
  EffectiveConfigJson?: string;
337
+
338
+ /**
339
+ * JSON of the server's resolved channel policy for this session (`RealtimeSessionClientPolicy`):
340
+ * which of the channel candidates the browser reported are in the session (registry kill switch,
341
+ * agent and app `channels` configuration applied), with each one's resolved display and config,
342
+ * plus the app/static client-tool tiers. Null when the caller reported no channel candidates (a
343
+ * client that predates channel scoping) — the browser then resolves the scope locally.
344
+ */
345
+ @Field(() => String, { nullable: true })
346
+ ClientPolicyJson?: string;
347
+
348
+ /**
349
+ * JSON of the session's live-avatar status (`RealtimeAvatarStatus` from `@memberjunction/ai`): whether the voiced
350
+ * agent asked for an avatar, whether the model renders it, and why not. The browser reads it after connecting and
351
+ * tells the user once when the call is audio only. Null when the agent asked for no avatar (its video setting is
352
+ * off).
353
+ */
354
+ @Field(() => String, { nullable: true })
355
+ AvatarStatusJson?: string;
290
356
  }
291
357
 
292
358
  /**
@@ -380,6 +446,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
380
446
  // state on — otherwise that cleanup silently no-ops on a freshly-constructed instance.
381
447
  private readonly clientSessionService = new RealtimeClientSessionService();
382
448
  private readonly sessionManager = new SessionManager(this.clientSessionService);
449
+ private readonly runIDVerifier = new SessionRunIDVerifier();
383
450
 
384
451
  /**
385
452
  * The reason a `Save()`/`Delete()` returned false, for a log line. `CompleteMessage` renders
@@ -414,9 +481,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
414
481
  * session is created.
415
482
  * 2. Resolve the co-agent id via the metadata-driven resolution chain (runtime `coAgentId` →
416
483
  * agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
417
- * see {@link RealtimeClientSessionResolver.resolveCoAgentID} for the full contract.
484
+ * see {@link ResolveRealtimeCoAgentID} for the full contract.
418
485
  * 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
419
- * config server-side — this is the authoritative target for all later relays.
486
+ * config — the target later relays use, re-authorized for the caller on each relay.
420
487
  * 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
421
488
  * On failure the just-created session is closed so no half-open session leaks.
422
489
  *
@@ -442,6 +509,12 @@ export class RealtimeClientSessionResolver extends ResolverBase {
442
509
  * `TypeConfiguration` ← this). **Authorization-gated**: requires the
443
510
  * `Realtime: Advanced Session Controls` authorization — unauthorized callers receive a
444
511
  * structured rejection (never a silent ignore). Must be a JSON object.
512
+ * @param showsAgentVideo `false` from an app that shows no agent video (no channel of it shows it: the embeddable
513
+ * widgets, the mobile app): the session asks the model for no avatar, and `AvatarStatusJson` says `host`. Absent or
514
+ * `true`: the app may show it. It only ever turns video off, so it needs no authorization.
515
+ * @param conversationBranchId Optional fork (`MJ: Conversation Branches.ID`) the session starts on;
516
+ * omitted is Main. It must be a fork of `conversationId`, else the start fails and no session is
517
+ * created. The session's history is read from that fork's path and its transcript is written there.
445
518
  *
446
519
  * @returns The ephemeral config + session linkage the browser needs to open its socket.
447
520
  */
@@ -460,10 +533,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
460
533
  @Arg('mediaCollectionId', () => String, { nullable: true }) mediaCollectionId?: string,
461
534
  @Arg('applicationId', () => String, { nullable: true }) applicationId?: string,
462
535
  @Arg('appContextJson', () => String, { nullable: true }) appContextJson?: string,
536
+ @Arg('channelCandidatesJson', () => String, { nullable: true }) channelCandidatesJson?: string,
537
+ @Arg('showsAgentVideo', () => Boolean, { nullable: true }) showsAgentVideo?: boolean,
538
+ @Arg('conversationBranchId', () => String, { nullable: true }) conversationBranchId?: string,
463
539
  ): Promise<StartRealtimeClientSessionResult> {
464
540
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
465
541
 
466
- const coAgentID = await this.resolveCoAgentID(targetAgentId, coAgentId, contextUser, provider);
542
+ const coAgentID = await ResolveRealtimeCoAgentID(targetAgentId, coAgentId, contextUser, provider);
467
543
  // PAIRING CONSTRAINTS: a co-agent with pairing rows is restricted to that target list
468
544
  // (with the IsDefault row standing in when no runtime target was supplied); zero rows =
469
545
  // universal, today's behavior untouched. Resolves the AUTHORITATIVE target id.
@@ -497,22 +573,32 @@ export class RealtimeClientSessionResolver extends ResolverBase {
497
573
  );
498
574
  }
499
575
  }
500
- const session = await this.sessionManager.CreateSession(
501
- {
502
- agentID: coAgentID,
503
- userID: contextUser.ID,
504
- conversationID: conversationId,
505
- lastSessionID: lastSessionId,
506
- config: JSON.stringify(config),
507
- },
508
- contextUser,
509
- provider,
576
+ const sessionBranchId = await this.resolveSessionBranch(conversationId, conversationBranchId, contextUser, provider);
577
+ if (sessionBranchId) {
578
+ config.conversationBranchID = sessionBranchId;
579
+ }
580
+ // The initial Config carries `maxSessionDeadlineIso` for capped sessions — a server-authoritative key
581
+ // that `MJAIAgentSessionEntityServer` refuses to let the session OWNER write, so this write (and only
582
+ // this one, built entirely from server-decided values) runs in the trusted scope.
583
+ const session = await RunWithTrustedSessionConfigWrites(() =>
584
+ this.sessionManager.CreateSession(
585
+ {
586
+ agentID: coAgentID,
587
+ userID: contextUser.ID,
588
+ conversationID: conversationId,
589
+ lastSessionID: lastSessionId,
590
+ config: JSON.stringify(config),
591
+ },
592
+ contextUser,
593
+ provider,
594
+ ),
510
595
  );
511
596
 
512
597
  // Best-effort: stamp recording-start metadata when the browser captured (with consent) at start.
513
598
  await this.stampRecordingStart(session, recordingConsent, recordingStartedAt);
514
599
 
515
600
  const clientTools = this.parseClientTools(clientToolsJson);
601
+ const channelCandidates = this.parseChannelCandidates(channelCandidatesJson);
516
602
  // Best-effort model-context hydration: the PRIOR session chain's transcript (ownership-
517
603
  // checked, capped) is framed into the system prompt so the model REMEMBERS the last leg.
518
604
  // Strictly tolerant — any problem yields no hydration, never a failed start.
@@ -522,24 +608,103 @@ export class RealtimeClientSessionResolver extends ResolverBase {
522
608
  // framed as the resumed transcript, and injecting the same turns twice puts two accounts of
523
609
  // the same exchange in one prompt.
524
610
  const conversationMessages = await this.loadConversationHistory(
525
- conversationId, prior?.LegIDs ?? [], contextUser, provider,
611
+ conversationId, prior?.LegIDs ?? [], contextUser, provider, sessionBranchId,
526
612
  );
527
613
  const result = await this.prepareClientSessionOrClose(
528
614
  session, coAgentID, effectiveTargetId, contextUser, provider, preferredModelId, clientTools, prior?.Text,
529
615
  configOverridesJson, maxSessionSeconds, applicationId, this.parseAppContext(appContextJson), conversationMessages,
616
+ channelCandidates, showsAgentVideo,
530
617
  );
618
+ await this.stampVerificationPolicy(session, result.EffectiveConfigJson, lastSessionId, contextUser, provider);
531
619
  // Best-effort restore of the PRIOR session's persisted channel states (e.g. the whiteboard
532
620
  // board). Strictly tolerant — any problem yields a null field, never a failed start.
533
621
  result.PriorChannelStatesJson = (await this.loadPriorChannelStatesJson(lastSessionId, contextUser, provider)) ?? undefined;
534
622
  return result;
535
623
  }
536
624
 
625
+ /**
626
+ * The fork a session starts on: null (Main) when none is named; else the named fork, which must
627
+ * belong to the session's conversation. Returns the fork's stored id.
628
+ * @throws when a fork is named without a conversation, or is not a fork of it, or the fork rows cannot be read
629
+ */
630
+ private async resolveSessionBranch(
631
+ conversationId: string | undefined,
632
+ branchId: string | undefined,
633
+ contextUser: UserInfo,
634
+ provider: IMetadataProvider,
635
+ ): Promise<string | null> {
636
+ const named = branchId?.trim();
637
+ if (!named) {
638
+ return null;
639
+ }
640
+ if (!conversationId) {
641
+ throw new Error(`StartRealtimeClientSession: fork ${named} needs a conversation`);
642
+ }
643
+ try {
644
+ const scope = await ConversationEngine.LoadScope(conversationId, named, contextUser, provider);
645
+ return scope.BranchID;
646
+ } catch (error) {
647
+ throw new Error(`StartRealtimeClientSession: fork ${named} is not a readable fork of conversation ${conversationId}: ${(error as Error).message}`);
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Stamps the identity-verification policy snapshot, the configured unverified duration cap and any
653
+ * verified identity inherited from the resumed session onto the new session's `Config`.
654
+ *
655
+ * The effective config (config cascade) is only known after prepare, so this is a second, narrowly
656
+ * scoped write: it runs only when there is something to stamp. It **fails closed** — a cap that could
657
+ * not be applied would leave the session uncapped, so on a save failure the session is closed and the
658
+ * start fails rather than proceeding without its limits.
659
+ */
660
+ private async stampVerificationPolicy(
661
+ session: MJAIAgentSessionEntity,
662
+ effectiveConfigJson: string | undefined,
663
+ lastSessionId: string | undefined,
664
+ contextUser: UserInfo,
665
+ provider: IMetadataProvider,
666
+ ): Promise<void> {
667
+ const plan = BuildMintConfigStamps({
668
+ EffectiveRealtime: this.readEffectiveRealtime(effectiveConfigJson),
669
+ ExistingConfigRaw: session.Config_,
670
+ InheritedVerified: await LoadInheritedVerifiedIdentity(lastSessionId, contextUser, provider),
671
+ NowMs: Date.now(),
672
+ });
673
+ if (!plan) {
674
+ return;
675
+ }
676
+ session.Config_ = plan.NextConfigRaw;
677
+ const saved = await RunWithTrustedSessionConfigWrites(() => session.Save());
678
+ if (!saved) {
679
+ LogError(
680
+ `RealtimeClientSessionResolver.stampVerificationPolicy save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
681
+ );
682
+ await this.sessionManager.CloseSession(session.ID, contextUser, provider, 'Error');
683
+ throw new Error('Failed to apply the session limits; the realtime session was not started.');
684
+ }
685
+ }
686
+
687
+ /** The `realtime` section of the resolved effective config JSON, or undefined when absent/unparseable. */
688
+ private readEffectiveRealtime(effectiveConfigJson: string | undefined): unknown {
689
+ if (!effectiveConfigJson) {
690
+ return undefined;
691
+ }
692
+ try {
693
+ return (JSON.parse(effectiveConfigJson) as { realtime?: unknown }).realtime;
694
+ } catch (error) {
695
+ LogError(`RealtimeClientSessionResolver: effective config JSON was unparseable while stamping verification policy: ${(error as Error).message}`);
696
+ return undefined;
697
+ }
698
+ }
699
+
537
700
  /**
538
701
  * Execute a single tool call the browser relayed from its provider socket and return the
539
702
  * serialized result for the browser to relay back to the model.
540
703
  *
541
- * Ownership-gated; the target agent id is read from the **session config** (authoritative), never
542
- * from the client. Heartbeats the session on success.
704
+ * Ownership-gated. The target agent id is read from the session config and `CanRun` on it is
705
+ * checked for the caller on every call, because the owner can edit the config. A direct-action
706
+ * call from a scope-limited caller is refused before any elevation. Heartbeats the session on
707
+ * success.
543
708
  *
544
709
  * @returns The serialized tool result JSON.
545
710
  */
@@ -555,13 +720,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
555
720
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
556
721
  const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
557
722
  const config = this.readSessionConfig(session);
723
+ await this.assertCanRunTarget(config.targetAgentID, contextUser, provider);
724
+ if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME && IsScopeLimitedPrincipal(contextUser)) {
725
+ return this.refuseDirectAction(agentSessionId, toolName);
726
+ }
558
727
 
559
728
  // SCOPED-ANONYMOUS ELEVATION (issue #3371): once ownership is proven above, the delegated
560
729
  // run + its AI-run-entity writes execute as the system user for a scoped anonymous caller
561
730
  // (the caller's role deliberately holds no grants on the run entities). The lead
562
- // targetAgentID comes from the session config and was CanRun-gated at start; the colleague
563
- // union is gated just below, against the CALLER, so elevation never widens agent authority.
731
+ // targetAgentID is CanRun-gated against the CALLER just above, and the colleague union
732
+ // just below, so elevation never widens agent authority.
564
733
  const runUser = ResolveScopedAnonymousRunUser(contextUser);
734
+ const runIDs = await this.runIDVerifier.Verify(
735
+ session.ID,
736
+ { CoAgentRunID: config.coAgentRunID, PendingFeedbackRunID: config.pendingFeedbackRunID },
737
+ runUser,
738
+ provider,
739
+ );
565
740
  if (runUser !== contextUser) {
566
741
  LogStatus(
567
742
  `ExecuteRealtimeSessionTool: dispatching relayed tool '${toolName}' for session ${agentSessionId} ` +
@@ -578,17 +753,24 @@ export class RealtimeClientSessionResolver extends ResolverBase {
578
753
  TargetAgentID: config.targetAgentID,
579
754
  // Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
580
755
  // colleague in the call is validated against this; absent ⇒ single-target behavior.
581
- AllowedAgents: await this.filterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
582
- DirectActions: config.directActions,
756
+ AllowedAgents: await FilterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
757
+ // No DirectActions: the service takes them from the session's start-time projection
758
+ // or the target agent's metadata, never from the owner-editable config.
583
759
  // Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
584
760
  // row and its context-memory scope must stay the person's, not the system user's.
585
761
  AttributionUserID: contextUser.ID,
586
762
  // Nest the delegated target-agent run under the co-agent observability run (when present).
587
- ParentRunID: config.coAgentRunID,
763
+ ParentRunID: runIDs.CoAgentRunID,
764
+ // The client-tool channel is keyed on the browser session id, so the delegated run can call
765
+ // the surface's tools; the session-start app context gives its prompt the surface context.
766
+ BrowserSessionID: userPayload.sessionId,
767
+ // The app context is a snapshot the browser sent, so a run elevated to the system user
768
+ // does not get it in its prompt.
769
+ AppContext: runUser === contextUser ? config.appContext : undefined,
588
770
  Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
589
771
  OnProgress: this.buildDelegationProgressCallback(pubSub, userPayload, agentSessionId, callId),
590
772
  // Resume a previously-paused delegated run (if any) with the user's answer.
591
- ResumeRunID: config.pendingFeedbackRunID,
773
+ ResumeRunID: runIDs.PendingFeedbackRunID,
592
774
  },
593
775
  runUser,
594
776
  provider,
@@ -598,7 +780,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
598
780
  `ExecuteRealtimeSessionTool: completed relayed tool '${toolName}' (callId: ${callId}) in ${durationMs}ms`,
599
781
  );
600
782
 
601
- if (toolName !== 'invoke-target-agent') {
783
+ if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME) {
602
784
  await this.persistDirectActionTurn(
603
785
  session,
604
786
  callId,
@@ -655,6 +837,19 @@ export class RealtimeClientSessionResolver extends ResolverBase {
655
837
  };
656
838
  }
657
839
 
840
+ /**
841
+ * The tool result for a direct-action call from a scope-limited caller (see the authorization
842
+ * model above). It has the broker's result shape, so the model hears that the tool is not
843
+ * available and can ask the target agent through `invoke-target-agent` instead.
844
+ */
845
+ private refuseDirectAction(agentSessionId: string, toolName: string): string {
846
+ LogStatus(`ExecuteRealtimeSessionTool: refused direct action '${toolName}' for a scope-limited caller on session ${agentSessionId}.`);
847
+ return JSON.stringify({
848
+ success: false,
849
+ output: `The tool '${toolName}' is not available in this session. Ask the agent to do it with ${INVOKE_TARGET_AGENT_TOOL_NAME}.`,
850
+ });
851
+ }
852
+
658
853
  /**
659
854
  * Rolls the session's `pendingFeedbackRunID` forward after a relayed tool call. The id we just
660
855
  * consumed (resumed or none) is dropped; a new paused run id is stored only when the run paused
@@ -670,17 +865,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
670
865
  if (!previous && !pausedRunID) {
671
866
  return;
672
867
  }
673
- const next: RealtimeSessionConfig = {
674
- targetAgentID: config.targetAgentID,
675
- coAgentRunID: config.coAgentRunID,
676
- promptRunID: config.promptRunID,
677
- coAgentRunStepID: config.coAgentRunStepID,
678
- pendingFeedbackRunID: pausedRunID,
679
- // Preserve the server-authoritative voice deadline across config rewrites.
680
- maxSessionDeadlineIso: config.maxSessionDeadlineIso,
681
- applicationID: config.applicationID,
682
- allowedAgents: config.allowedAgents,
683
- };
868
+ // Start from the RAW stored config so every key survives the rewrite — including the
869
+ // server-authoritative `maxSessionDeadlineIso` and `identityVerification` (dropping either would be
870
+ // refused by `MJAIAgentSessionEntityServer`, and silently losing a verification would be worse).
871
+ const next = { ...ParseConfigObject(session.Config_), targetAgentID: config.targetAgentID, pendingFeedbackRunID: pausedRunID };
684
872
  session.Config_ = JSON.stringify(next);
685
873
  if (!(await session.Save())) {
686
874
  LogError(
@@ -725,14 +913,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
725
913
  // conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
726
914
  // The prompt-run write runs as the scoped-anonymous elevated user (issue #3371) — the visible
727
915
  // Conversation Detail above deliberately stays on the caller.
728
- const promptRunID = this.readPromptRunID(session);
916
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
917
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
729
918
  if (promptRunID) {
730
919
  await this.clientSessionService.AppendPromptRunMessage(
731
920
  promptRunID,
732
921
  this.mapTranscriptRoleToChatRole(role),
733
922
  text,
734
923
  replacesPrevious ?? false,
735
- ResolveScopedAnonymousRunUser(contextUser),
924
+ runUser,
736
925
  provider,
737
926
  );
738
927
  }
@@ -791,11 +980,11 @@ export class RealtimeClientSessionResolver extends ResolverBase {
791
980
  return { Success: false, ErrorMessage: 'Recording consent was not granted.' };
792
981
  }
793
982
 
794
- // SCOPED-ANONYMOUS ELEVATION (issue #3371): past the ownership + consent gates, the store is
795
- // server-side plumbing over entities (MJ: AI Agents read, MJ: Files, the file-session link)
796
- // the caller's narrow relay role deliberately does not hold. Attribution flows through the
797
- // session link, so nothing here depends on the caller's identity.
798
- const runUser = ResolveScopedAnonymousRunUser(contextUser);
983
+ // ELEVATION (issue #3371, #5195): past the ownership + consent gates, the store is server-side
984
+ // plumbing over entities (MJ: AI Agents read, MJ: Files, the file-session link, the session's
985
+ // recording stamp) that neither a scoped-anonymous relay role nor the stock UI role holds.
986
+ // Attribution flows through the session link, so nothing here depends on the caller's identity.
987
+ const runUser = ResolveRecordingStoreUser(contextUser);
799
988
  try {
800
989
  const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
801
990
  if (!(await agent.Load(session.AgentID))) {
@@ -846,11 +1035,14 @@ export class RealtimeClientSessionResolver extends ResolverBase {
846
1035
 
847
1036
  /**
848
1037
  * Uploads ONE crash-recovery audio shard (~15s) for an IN-PROGRESS recording into the session's
849
- * folder (`realtime-recordings/<sessionId>/seg-NNNN.<ext>`) as a raw storage object. Durability
850
- * insurance during a live call so a browser/tab death loses at most the last window; the shards are
851
- * deleted once the canonical consolidated file lands via {@link UploadRealtimeRecording}. Ownership-
852
- * gated; consent was already established at session start. Best-effort — returns `false` (never
853
- * throws) on any problem so a failed shard never disrupts the live call.
1038
+ * folder (`realtime-recordings/<sessionId>/seg-NNNN.r<rate>.pcm` for PCM with a rate in its MIME type,
1039
+ * else the legacy `seg-NNNN.<ext>`) as a raw storage object. Durability insurance during a live call
1040
+ * so a browser/tab death loses at most the last window; the shards are deleted once the canonical
1041
+ * consolidated file lands via {@link UploadRealtimeRecording}, or by the session janitor once it has
1042
+ * recovered a recording from them when that upload never arrives. Ownership-
1043
+ * gated; consent was already established at session start. Refused (`false`) once the session has
1044
+ * its consolidated recording, since nothing would delete a shard written after that. Best-effort —
1045
+ * returns `false` (never throws) on any problem so a failed shard never disrupts the live call.
854
1046
  *
855
1047
  * @param agentSessionId The in-progress session (ownership-gated).
856
1048
  * @param segmentIndex 0-based shard index within the session.
@@ -869,6 +1061,12 @@ export class RealtimeClientSessionResolver extends ResolverBase {
869
1061
  try {
870
1062
  const { contextUser, provider } = this.requireUserAndProvider(ctx.userPayload, ctx.providers);
871
1063
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
1064
+ // The consolidated recording already landed and its upload deleted the shards; a shard
1065
+ // arriving now (one still on the wire at hang-up) would be an orphan nothing deletes.
1066
+ if (session.RecordingFileID) {
1067
+ LogStatus(`RealtimeClientSessionResolver.UploadRealtimeRecordingSegment: shard ${segmentIndex} for session ${agentSessionId} refused — the session already has its consolidated recording`);
1068
+ return false;
1069
+ }
872
1070
  // Scoped-anonymous elevation (issue #3371) — same rationale as UploadRealtimeRecording.
873
1071
  const runUser = ResolveScopedAnonymousRunUser(contextUser);
874
1072
  const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
@@ -930,7 +1128,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
930
1128
  provider,
931
1129
  );
932
1130
 
933
- const promptRunID = this.readPromptRunID(session);
1131
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
1132
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
934
1133
  if (!promptRunID) {
935
1134
  return false;
936
1135
  }
@@ -939,7 +1138,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
939
1138
  'assistant',
940
1139
  this.formatToolTurn(toolName, argsJson, resultJson),
941
1140
  false,
942
- ResolveScopedAnonymousRunUser(contextUser),
1141
+ runUser,
943
1142
  provider,
944
1143
  );
945
1144
  }
@@ -1011,8 +1210,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1011
1210
  * (observability creation was skipped), a failed load, or a failed save all log and return
1012
1211
  * `false` — usage relay must never break a live call.
1013
1212
  *
1213
+ * The optional details are the per-modality usage since the last relay: input and output
1214
+ * blocks shaped like Core's `RealtimeUsageModalityDetail`, avatar video seconds included. They
1215
+ * add into the usage record on the run's `ModelSpecificResponseDetails`, which prices the avatar
1216
+ * video at finalize. Only the fields a block defines, with values that are finite numbers of at
1217
+ * least 0, are kept; a relay that carries only details (avatar seconds, no tokens) is stored.
1218
+ *
1014
1219
  * @param inputTokens Input-token DELTA to add (negative/non-finite values are clamped to 0).
1015
1220
  * @param outputTokens Output-token DELTA to add (negative/non-finite values are clamped to 0).
1221
+ * @param usageDetailsJson The per-modality usage since the last relay, as JSON (`{ Input, Output }`).
1016
1222
  * @returns `true` when the accumulated usage was persisted; `false` on any tolerated failure.
1017
1223
  */
1018
1224
  @Mutation(() => Boolean)
@@ -1021,25 +1227,28 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1021
1227
  @Arg('inputTokens', () => Int) inputTokens: number,
1022
1228
  @Arg('outputTokens', () => Int) outputTokens: number,
1023
1229
  @Ctx() { userPayload, providers }: AppContext,
1230
+ @Arg('usageDetailsJson', () => String, { nullable: true }) usageDetailsJson?: string,
1024
1231
  ): Promise<boolean> {
1025
1232
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
1026
1233
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
1027
1234
 
1028
1235
  const inputDelta = this.clampTokenDelta(inputTokens);
1029
1236
  const outputDelta = this.clampTokenDelta(outputTokens);
1030
- if (inputDelta === 0 && outputDelta === 0) {
1237
+ const details = this.readUsageDetails(usageDetailsJson);
1238
+ if (inputDelta === 0 && outputDelta === 0 && !details) {
1031
1239
  return true; // nothing to add — a no-op flush is a success, not a failure
1032
1240
  }
1033
1241
 
1034
- const promptRunID = this.readPromptRunID(session);
1242
+ // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
1243
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
1244
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
1035
1245
  if (!promptRunID) {
1036
1246
  return false; // no observability prompt run for this session — logged in the helper
1037
1247
  }
1038
1248
  // Delegate to the service so usage writes share the per-run serialization with transcript-message
1039
1249
  // appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
1040
- // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
1041
1250
  return this.clientSessionService.AccumulatePromptRunUsage(
1042
- promptRunID, inputDelta, outputDelta, ResolveScopedAnonymousRunUser(contextUser), provider,
1251
+ promptRunID, inputDelta, outputDelta, runUser, provider, details ?? undefined,
1043
1252
  );
1044
1253
  }
1045
1254
 
@@ -1048,6 +1257,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1048
1257
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1049
1258
  }
1050
1259
 
1260
+ /**
1261
+ * Reads the relayed per-modality usage, or `null` when there is none worth storing: absent,
1262
+ * malformed, longer than a relay ever needs (a client cannot make the server parse an
1263
+ * arbitrarily large string), or holding no amount above 0.
1264
+ */
1265
+ private readUsageDetails(usageDetailsJson: string | undefined): RealtimeUsageRecord | null {
1266
+ if (typeof usageDetailsJson !== 'string') {
1267
+ return null;
1268
+ }
1269
+ if (usageDetailsJson.length > MAX_USAGE_DETAILS_JSON_LENGTH) {
1270
+ LogError(`RelayRealtimeUsage: usage details of ${usageDetailsJson.length} characters exceed ${MAX_USAGE_DETAILS_JSON_LENGTH} — details dropped.`);
1271
+ return null;
1272
+ }
1273
+ const details = ParseRealtimeUsageRecord(usageDetailsJson);
1274
+ return HasRealtimeUsage(details) ? details : null;
1275
+ }
1276
+
1051
1277
  /**
1052
1278
  * Sanitizes client-supplied waveform peaks before they're persisted: drops anything that isn't a
1053
1279
  * finite number, clamps each to `[0, 1]`, and caps the array length so a hostile client can't bloat
@@ -1067,24 +1293,40 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1067
1293
 
1068
1294
  /**
1069
1295
  * Reads the co-agent `AIPromptRun` id from the session config WITHOUT the relay path's
1070
- * throw-on-missing-target semantics — usage relay is best-effort, so a missing/malformed
1071
- * config or absent id just logs and returns `null`.
1296
+ * throw-on-missing-target semantics, and keeps it only when it is the prompt run of the
1297
+ * session's co-agent run (the owner can edit the config). Usage relay is best-effort, so a
1298
+ * missing/malformed config, an absent id, or an id of another session just logs and returns `null`.
1299
+ *
1300
+ * @param runUser The user the prompt-run write runs as; the check runs as the same user.
1072
1301
  */
1073
- private readPromptRunID(session: MJAIAgentSessionEntity): string | null {
1074
- try {
1075
- const config = this.readSessionConfig(session);
1076
- if (config.promptRunID) {
1077
- return config.promptRunID;
1078
- }
1302
+ private async readPromptRunID(
1303
+ session: MJAIAgentSessionEntity,
1304
+ runUser: UserInfo,
1305
+ provider: IMetadataProvider,
1306
+ ): Promise<string | null> {
1307
+ const config = this.tryReadSessionConfig(session);
1308
+ if (!config) {
1309
+ LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
1310
+ return null;
1311
+ }
1312
+ if (!config.promptRunID) {
1079
1313
  LogStatus(
1080
1314
  `RelayRealtimeUsage: session ${session.ID} has no co-agent promptRunID (observability run ` +
1081
1315
  'creation was skipped or failed) — usage delta dropped.',
1082
1316
  );
1083
1317
  return null;
1084
- } catch {
1085
- LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
1318
+ }
1319
+ const verified = await this.runIDVerifier.Verify(
1320
+ session.ID,
1321
+ { CoAgentRunID: config.coAgentRunID, PromptRunID: config.promptRunID },
1322
+ runUser,
1323
+ provider,
1324
+ );
1325
+ if (!verified.PromptRunID) {
1326
+ LogError(`RelayRealtimeUsage: session ${session.ID} names a promptRunID that is not its co-agent prompt run — ignored.`);
1086
1327
  return null;
1087
1328
  }
1329
+ return verified.PromptRunID;
1088
1330
  }
1089
1331
 
1090
1332
  /**
@@ -1118,6 +1360,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1118
1360
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
1119
1361
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
1120
1362
 
1363
+ if (!this.isChannelInSessionScope(session, channelName)) {
1364
+ LogError(`SaveSessionChannelState: channel '${channelName}' is not in session ${agentSessionId}'s resolved scope — state not saved.`);
1365
+ return false;
1366
+ }
1121
1367
  if (stateJson.length > MAX_CHANNEL_STATE_CHARS) {
1122
1368
  LogError(
1123
1369
  `SaveSessionChannelState: rejected oversized state for session ${agentSessionId} / channel '${channelName}' ` +
@@ -1197,6 +1443,11 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1197
1443
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
1198
1444
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
1199
1445
 
1446
+ if (!this.isChannelInSessionScope(session, channelName)) {
1447
+ const message = `SaveSessionChannelArtifact: channel '${channelName}' is not in session ${agentSessionId}'s resolved scope — nothing saved.`;
1448
+ LogError(message);
1449
+ return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
1450
+ }
1200
1451
  if (contentJson.length > MAX_CHANNEL_STATE_CHARS) {
1201
1452
  const message =
1202
1453
  `SaveSessionChannelArtifact: rejected oversized content for session ${agentSessionId} / channel '${channelName}' ` +
@@ -1234,8 +1485,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1234
1485
  }
1235
1486
 
1236
1487
  /**
1237
- * Authorization gate for {@link RealtimeClientSessionResolver.StartRealtimeClientSession}: the
1238
- * caller must be able to run the **target** agent. Denial throws (no session is created).
1488
+ * Authorization gate for the **target** agent: the caller must be able to run it. Denial throws.
1489
+ * {@link RealtimeClientSessionResolver.StartRealtimeClientSession} runs it before any session is
1490
+ * created, and {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} runs it on every
1491
+ * relayed call, because the owner can edit the target in the session config.
1239
1492
  */
1240
1493
  private async assertCanRunTarget(
1241
1494
  targetAgentId: string,
@@ -1250,39 +1503,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1250
1503
  }
1251
1504
  }
1252
1505
 
1253
- /**
1254
- * Narrows a session's colleague union (`allowedAgents`) to the agents the CALLER may run.
1255
- *
1256
- * {@link assertCanRunTarget} gates the LEAD target at session start, but the union it travels
1257
- * with was never gated at all — a model-named colleague resolves straight to a delegated run.
1258
- * That was survivable while the run carried the caller's own identity, because base-agent
1259
- * re-checks `CanRun` against `contextUser`. Once the run user is elevated for a scoped anonymous
1260
- * caller (issue #3371) that check sees the SYSTEM user, so this is the only remaining place the
1261
- * caller's own authority is applied to a colleague. It therefore runs for EVERY caller, elevated
1262
- * or not — the authorization identity must never depend on the elevation decision.
1263
- *
1264
- * `HasPermission` reads AIEngineBase's in-memory caches (no DB round trip) and already fails
1265
- * closed on error, so an unresolvable agent drops OUT of the union rather than becoming runnable.
1266
- * A filtered-out colleague is not an error: the delegation layer reports it as "not available in
1267
- * this session" and lists what remains, which is the same answer the model gets for a typo.
1268
- *
1269
- * @param allowedAgents The session's persisted colleague union (absent/empty ⇒ single-target).
1270
- * @param contextUser The ORIGINAL caller — never the elevated run user.
1271
- * @returns The subset the caller may run, preserving order.
1272
- */
1273
- private async filterAllowedAgentsByCanRun(
1274
- allowedAgents: RealtimeAllowedAgent[] | undefined,
1275
- contextUser: UserInfo,
1276
- ): Promise<RealtimeAllowedAgent[] | undefined> {
1277
- if (!allowedAgents || allowedAgents.length === 0) {
1278
- return allowedAgents;
1279
- }
1280
- const verdicts = await Promise.all(
1281
- allowedAgents.map((a) => AIAgentPermissionHelper.HasPermission(a.agentId, contextUser, 'run')),
1282
- );
1283
- return allowedAgents.filter((_, i) => verdicts[i]);
1284
- }
1285
-
1286
1506
  /**
1287
1507
  * Resolves the AUTHORITATIVE target agent id under the co-agent's PAIRING CONSTRAINTS
1288
1508
  * (`MJ: AI Agent Co Agents`, ordered by `Sequence`):
@@ -1525,195 +1745,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1525
1745
  }
1526
1746
  }
1527
1747
 
1528
- /**
1529
- * Resolves the co-agent (the Realtime-type agent that voices the target agent) for a new
1530
- * client-direct session via the metadata-driven **CO-AGENT RESOLUTION CHAIN** — first match
1531
- * wins, evaluated in precedence order:
1532
- *
1533
- * 1. **Runtime parameter** — the mutation's explicit `coAgentId`. A per-call override; an
1534
- * invalid candidate (unknown, not Active, or not of the Realtime agent type) **throws**
1535
- * (fail loud — the caller asked for something specific, mirroring `preferredModelId`).
1536
- * 2. **Per-agent persona** — the target agent's `AIAgent.DefaultCoAgentID`. An invalid
1537
- * reference logs a warning and **falls through** to the next step (stale metadata should
1538
- * degrade gracefully, never break calls).
1539
- * 3. **Per-type default** — an Active `AIAgentCoAgent` row of Type `'CoAgent'` whose
1540
- * `TargetAgentTypeID` is the target agent's type and `IsDefault = 1` (lowest `Sequence`
1541
- * wins a tie). Same tolerant warn-and-fall-through semantics as step 2.
1542
- * 4. **Global default** — the seeded {@link REALTIME_CO_AGENT_NAME} agent, looked up by name
1543
- * (with a deprecated fallback to {@link LEGACY_REALTIME_CO_AGENT_NAME}). Throws when absent
1544
- * entirely (the realtime feature is unconfigured in this deployment).
1545
- *
1546
- * Every candidate from steps 1–3 is validated by {@link findValidCoAgent}: it must exist in
1547
- * {@link AIEngine}'s cached agents, have Status `Active`, and be of the
1548
- * {@link REALTIME_AGENT_TYPE_NAME} agent type. Step 4 keeps its original name-lookup contract.
1549
- *
1550
- * @param targetAgentId The agent the co-agent will voice on behalf of (drives steps 2–3).
1551
- * @param explicitCoAgentId The runtime `coAgentId` mutation argument, when supplied (step 1).
1552
- * @returns The resolved co-agent's id (canonical casing from the metadata cache).
1553
- */
1554
- private async resolveCoAgentID(
1555
- targetAgentId: string | undefined,
1556
- explicitCoAgentId: string | undefined,
1557
- contextUser: UserInfo,
1558
- provider: IMetadataProvider,
1559
- ): Promise<string> {
1560
- await AIEngine.Instance.Config(false, contextUser, provider);
1561
-
1562
- // Step 1 — explicit runtime parameter: fail LOUD on any problem.
1563
- if (explicitCoAgentId) {
1564
- const { agent, problem } = this.findValidCoAgent(explicitCoAgentId);
1565
- if (!agent) {
1566
- throw new Error(`Invalid coAgentId '${explicitCoAgentId}': ${problem}`);
1567
- }
1568
- return agent.ID;
1569
- }
1570
-
1571
- const targetAgent = targetAgentId
1572
- ? (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, targetAgentId))
1573
- : undefined;
1574
-
1575
- // Step 2 — the target agent's own DefaultCoAgentID (per-agent persona): warn + fall through.
1576
- const fromAgent = this.resolveMetadataDefault(
1577
- targetAgent?.DefaultCoAgentID,
1578
- `agent '${targetAgent?.Name}' (DefaultCoAgentID)`,
1579
- );
1580
- if (fromAgent) {
1581
- return fromAgent;
1582
- }
1583
-
1584
- // Step 3 — the type-level AIAgentCoAgent default row (per-type default): warn + fall through.
1585
- const agentType = targetAgent?.TypeID
1586
- ? (AIEngine.Instance.AgentTypes ?? []).find(t => UUIDsEqual(t.ID, targetAgent.TypeID))
1587
- : undefined;
1588
- const typeDefaultCoAgentID = agentType
1589
- ? this.findTypeDefaultCoAgentID(agentType.ID, contextUser, provider)
1590
- : undefined;
1591
- const fromType = this.resolveMetadataDefault(
1592
- await typeDefaultCoAgentID,
1593
- `agent type '${agentType?.Name}' (AIAgentCoAgent type-default row)`,
1594
- );
1595
- if (fromType) {
1596
- return fromType;
1597
- }
1598
-
1599
- // Step 4 — global default: the seeded Realtime Co-Agent, by name (original behavior).
1600
- return this.resolveGlobalCoAgentID();
1601
- }
1602
-
1603
- /**
1604
- * Chain step 3 lookup: the TYPE-LEVEL default co-agent for an agent type — the Active
1605
- * `AIAgentCoAgent` row of Type `'CoAgent'` whose `TargetAgentTypeID` matches, with
1606
- * `IsDefault = 1` preferred and the lowest `Sequence` breaking ties (so a deployment can
1607
- * stage multiple type-level candidates deterministically). Reads {@link AIEngineBase}'s
1608
- * cached rows — no RunView. Tolerant: a failed cache read logs and returns `undefined`
1609
- * (the chain falls through to the global default).
1610
- */
1611
- private async findTypeDefaultCoAgentID(
1612
- agentTypeID: string,
1613
- contextUser: UserInfo,
1614
- provider: IMetadataProvider,
1615
- ): Promise<string | undefined> {
1616
- try {
1617
- const engine = await this.configuredAIEngineBase(contextUser, provider);
1618
- const candidates = (engine.AgentCoAgents ?? [])
1619
- .filter((r) =>
1620
- r.Type === 'CoAgent' &&
1621
- r.Status === 'Active' &&
1622
- r.TargetAgentTypeID != null &&
1623
- UUIDsEqual(r.TargetAgentTypeID, agentTypeID))
1624
- .sort((a, b) =>
1625
- (a.IsDefault === b.IsDefault ? 0 : a.IsDefault ? -1 : 1) ||
1626
- (a.Sequence ?? 0) - (b.Sequence ?? 0));
1627
- return candidates[0]?.CoAgentID;
1628
- } catch (error) {
1629
- LogError(
1630
- `StartRealtimeClientSession: ${CO_AGENT_ENTITY} cache read failed while resolving the type-level ` +
1631
- `default co-agent for agent type ${agentTypeID} (${(error as Error).message}) — falling through.`,
1632
- );
1633
- return undefined;
1634
- }
1635
- }
1636
-
1637
- /**
1638
- * Validates one metadata-level co-agent default (chain steps 2/3). Returns the resolved
1639
- * co-agent id when the reference is valid; logs a warning and returns `null` (caller falls
1640
- * through to the next chain step) when the reference is set but invalid — metadata drift must
1641
- * degrade, not break live calls. A `null`/absent reference returns `null` silently.
1642
- */
1643
- private resolveMetadataDefault(candidateId: string | null | undefined, source: string): string | null {
1644
- if (!candidateId) {
1645
- return null;
1646
- }
1647
- const { agent, problem } = this.findValidCoAgent(candidateId);
1648
- if (agent) {
1649
- return agent.ID;
1650
- }
1651
- LogError(
1652
- `StartRealtimeClientSession: ignoring co-agent default '${candidateId}' from ${source} — ${problem} ` +
1653
- 'Falling through to the next step of the co-agent resolution chain.',
1654
- );
1655
- return null;
1656
- }
1657
-
1658
- /**
1659
- * Validates a co-agent candidate against {@link AIEngine}'s cached metadata. A valid co-agent
1660
- * must (a) exist, (b) have Status `Active`, and (c) be of the {@link REALTIME_AGENT_TYPE_NAME}
1661
- * agent type. Returns the cached agent on success, or a human-readable `problem` on failure —
1662
- * the CALLER decides whether that's fatal (explicit runtime param) or tolerated (metadata default).
1663
- */
1664
- private findValidCoAgent(candidateId: string): { agent?: MJAIAgentEntityExtended; problem?: string } {
1665
- const agent = (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, candidateId));
1666
- if (!agent) {
1667
- return { problem: 'no agent with that ID exists.' };
1668
- }
1669
- if (agent.Status !== 'Active') {
1670
- return { problem: `agent '${agent.Name}' is not Active (Status: ${agent.Status}).` };
1671
- }
1672
- const realtimeType = (AIEngine.Instance.AgentTypes ?? []).find(
1673
- t => t.Name?.trim().toLowerCase() === REALTIME_AGENT_TYPE_NAME.toLowerCase(),
1674
- );
1675
- if (!realtimeType) {
1676
- return { problem: `the '${REALTIME_AGENT_TYPE_NAME}' agent type is not configured in this deployment.` };
1677
- }
1678
- if (!agent.TypeID || !UUIDsEqual(agent.TypeID, realtimeType.ID)) {
1679
- return { problem: `agent '${agent.Name}' is not of the '${REALTIME_AGENT_TYPE_NAME}' agent type.` };
1680
- }
1681
- return { agent };
1682
- }
1683
-
1684
- /**
1685
- * Resolves the seeded Realtime Co-Agent's id from {@link AIEngine}'s cached agents — the GLOBAL
1686
- * DEFAULT (step 4) of the co-agent resolution chain. The engine is already configured by
1687
- * {@link resolveCoAgentID}. Looks up the current {@link REALTIME_CO_AGENT_NAME} first, then
1688
- * falls back to the DEPRECATED {@link LEGACY_REALTIME_CO_AGENT_NAME} (pre-rename seed) with a
1689
- * deprecation log so un-resynced deployments keep working. Throws a clear error when neither
1690
- * name is present in metadata.
1691
- */
1692
- private resolveGlobalCoAgentID(): string {
1693
- const coAgent = this.findAgentByName(REALTIME_CO_AGENT_NAME);
1694
- if (coAgent) {
1695
- return coAgent.ID;
1696
- }
1697
- const legacy = this.findAgentByName(LEGACY_REALTIME_CO_AGENT_NAME);
1698
- if (legacy) {
1699
- LogStatus(
1700
- `StartRealtimeClientSession: resolved the global co-agent via its DEPRECATED legacy name ` +
1701
- `'${LEGACY_REALTIME_CO_AGENT_NAME}'. Re-sync the agent seed metadata to rename it to ` +
1702
- `'${REALTIME_CO_AGENT_NAME}'.`,
1703
- );
1704
- return legacy.ID;
1705
- }
1706
- throw new Error(
1707
- `The '${REALTIME_CO_AGENT_NAME}' agent is not configured; cannot start a realtime voice session.`,
1708
- );
1709
- }
1710
-
1711
- /** Case/whitespace-insensitive agent lookup by Name in {@link AIEngine}'s cached agents. */
1712
- private findAgentByName(name: string): MJAIAgentEntityExtended | undefined {
1713
- const wanted = name.toLowerCase();
1714
- return (AIEngine.Instance.Agents ?? []).find(a => a.Name?.trim().toLowerCase() === wanted);
1715
- }
1716
-
1717
1748
  /**
1718
1749
  * Mints the client session config for a just-created session. On failure, closes the session so
1719
1750
  * no half-open record leaks, then throws the service's error message.
@@ -1725,6 +1756,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1725
1756
  * resumed session remembers the previous leg(s).
1726
1757
  * @param conversationMessages Optional capped history of the TEXT conversation this session is
1727
1758
  * starting from (from {@link loadConversationHistory}) — framed as "Conversation so far".
1759
+ * @param showsAgentVideo `false` when the app shows no agent video: the session asks for no avatar (`host`).
1728
1760
  */
1729
1761
  private async prepareClientSessionOrClose(
1730
1762
  session: MJAIAgentSessionEntity,
@@ -1740,6 +1772,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1740
1772
  applicationId?: string,
1741
1773
  appContext?: AppContextSnapshot,
1742
1774
  conversationMessages?: ChatMessage[],
1775
+ channelCandidates?: RealtimeChannelCandidate[],
1776
+ showsAgentVideo?: boolean,
1743
1777
  ): Promise<StartRealtimeClientSessionResult> {
1744
1778
  const prep = await this.clientSessionService.PrepareClientSession(
1745
1779
  {
@@ -1767,6 +1801,14 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1767
1801
  // (allowed-agent union); the snapshot is injected into the system prompt at mint.
1768
1802
  ApplicationID: applicationId,
1769
1803
  AppContext: appContext,
1804
+ // Scope-limited callers get no direct actions (see the authorization model above), so
1805
+ // the model is not given tools that the relay refuses.
1806
+ ExcludeDirectActions: IsScopeLimitedPrincipal(contextUser),
1807
+ // The channels the browser could mount: scoped here (registry + cascade) and handed back
1808
+ // as the resolved policy; the declared tools are narrowed to match.
1809
+ ChannelCandidates: channelCandidates,
1810
+ // An app with no channel that shows the agent's video: no avatar is asked for (reason `host`).
1811
+ ShowsAgentVideo: showsAgentVideo ?? undefined,
1770
1812
  },
1771
1813
  // SCOPED-ANONYMOUS ELEVATION (issue #3371): the prepare creates the co-agent
1772
1814
  // observability AIAgentRun/AIPromptRun/run-step, which a scoped anonymous caller's role
@@ -1787,7 +1829,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1787
1829
  session, targetAgentId, prep.CoAgentRunID, prep.PromptRunID, prep.CoAgentRunStepID,
1788
1830
  applicationId, prep.EffectiveConfig?.realtime?.allowedAgents,
1789
1831
  prep.EffectiveConfig?.realtime?.directActions,
1832
+ prep.ClientPolicy?.Channels.map((c) => c.Key),
1833
+ appContext,
1790
1834
  );
1835
+ this.pruneExcludedServerChannels(session.ID, prep.ClientPolicy);
1791
1836
 
1792
1837
  const cfg = prep.ClientConfig;
1793
1838
  return {
@@ -1797,20 +1842,43 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1797
1842
  Model: cfg.Model,
1798
1843
  EphemeralToken: cfg.EphemeralToken,
1799
1844
  ExpiresAt: cfg.ExpiresAt,
1845
+ Transport: cfg.Transport,
1846
+ RelayUrl: cfg.RelayUrl,
1800
1847
  SessionConfigJson: JSON.stringify(cfg.SessionConfig),
1801
1848
  ModelName: prep.ModelName,
1802
1849
  DriverClass: prep.DriverClass,
1803
1850
  NarrationInstructionsTemplate: prep.NarrationInstructionsTemplate,
1804
1851
  NarrationPaceMs: prep.NarrationPaceMs,
1805
1852
  EffectiveConfigJson: prep.EffectiveConfig ? JSON.stringify(prep.EffectiveConfig) : undefined,
1853
+ ClientPolicyJson: prep.ClientPolicy ? JSON.stringify(prep.ClientPolicy) : undefined,
1854
+ AvatarStatusJson: prep.AvatarStatus ? JSON.stringify(prep.AvatarStatus) : undefined,
1806
1855
  };
1807
1856
  }
1808
1857
 
1858
+ /**
1859
+ * Drops the server-side channel plugins of channels the resolved scope left out. Server plugins
1860
+ * start when the session row is created — before the scope is known — so an excluded channel's
1861
+ * server half would otherwise keep running. A no-op when the caller reported no candidates.
1862
+ */
1863
+ private pruneExcludedServerChannels(agentSessionID: string, policy: RealtimeSessionClientPolicy | undefined): void {
1864
+ const excluded = (policy?.ExcludedChannels ?? []).map((e) => e.Key);
1865
+ if (excluded.length === 0) {
1866
+ return;
1867
+ }
1868
+ const dropped = RealtimeChannelServerHost.Instance.PruneSessionChannels(agentSessionID, excluded);
1869
+ if (dropped.length > 0) {
1870
+ LogStatus(`StartRealtimeClientSession: dropped the server plugins of out-of-scope channel(s) [${dropped.join(', ')}] for session ${agentSessionID}.`);
1871
+ }
1872
+ }
1873
+
1809
1874
  /**
1810
1875
  * Writes the co-agent observability run ids into the session's `Config_` alongside the
1811
- * authoritative `targetAgentID`, then saves the session. These ids are read back on close to
1812
- * finalize the runs (and on relay to nest delegated runs). Best-effort: a save failure is logged,
1813
- * not thrown — the voice session still proceeds, it just won't carry the run ids.
1876
+ * `targetAgentID`, then saves the session. These ids are read back on close to
1877
+ * finalize the runs (and on relay to nest delegated runs). The app awareness (application id,
1878
+ * allowed agents, direct actions, app-context snapshot) is written with them; relays read the
1879
+ * allowed agents and the app context, never the direct actions.
1880
+ * Best-effort: a save failure is logged, not thrown — the voice session still proceeds, it just
1881
+ * won't carry the run ids.
1814
1882
  */
1815
1883
  private async persistObservabilityRunIDs(
1816
1884
  session: MJAIAgentSessionEntity,
@@ -1821,22 +1889,29 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1821
1889
  applicationID?: string,
1822
1890
  allowedAgents?: RealtimeAllowedAgent[],
1823
1891
  directActions?: RealtimeDirectActionsConfig,
1892
+ channels?: string[],
1893
+ appContext?: AppContextSnapshot,
1824
1894
  ): Promise<void> {
1825
- // Preserve any server-authoritative voice deadline stamped at session start (this rebuilds the
1826
- // full config, so read the existing value forward rather than dropping it).
1827
- const existing = this.tryReadSessionConfig(session);
1828
- const config: RealtimeSessionConfig = {
1895
+ // Start from the RAW stored config so every key set at session start survives — the
1896
+ // server-authoritative voice deadline (`maxSessionDeadlineIso`) and the per-session media-kit
1897
+ // override among them — rather than rebuilding from a whitelist that silently drops the rest.
1898
+ const config = {
1899
+ ...ParseConfigObject(session.Config_),
1829
1900
  targetAgentID,
1830
1901
  coAgentRunID,
1831
1902
  promptRunID,
1832
1903
  coAgentRunStepID,
1833
- maxSessionDeadlineIso: existing?.maxSessionDeadlineIso,
1834
1904
  applicationID,
1905
+ appContext: this.appContextToStore(appContext, session.ID),
1835
1906
  allowedAgents: allowedAgents && allowedAgents.length > 0 ? allowedAgents : undefined,
1836
1907
  directActions,
1908
+ channels,
1837
1909
  };
1838
1910
  session.Config_ = JSON.stringify(config);
1839
- const saved = await session.Save();
1911
+ // Every value written here is server-decided (run ids, the resolved target, the in-scope channel
1912
+ // set), and `channels` is a server-authoritative key the owner may not write — so this save runs in
1913
+ // the trusted scope. Protected keys already in the stored config are carried forward unchanged.
1914
+ const saved = await RunWithTrustedSessionConfigWrites(() => session.Save());
1840
1915
  if (!saved) {
1841
1916
  LogError(
1842
1917
  `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
@@ -1844,6 +1919,32 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1844
1919
  }
1845
1920
  }
1846
1921
 
1922
+ /**
1923
+ * Returns the app-context snapshot to store on the session config. A snapshot whose JSON is longer
1924
+ * than {@link MAX_CLIENT_TOOLS_JSON_CHARS} is not stored: a warning is logged and `undefined` is
1925
+ * returned, so the session still starts and its delegated runs get no app context.
1926
+ *
1927
+ * @param appContext The snapshot the browser sent at session start, or undefined.
1928
+ * @param sessionID The session the snapshot belongs to (for the warning).
1929
+ * @returns The snapshot to store, or `undefined`.
1930
+ */
1931
+ private appContextToStore(appContext: AppContextSnapshot | undefined, sessionID: string): AppContextSnapshot | undefined {
1932
+ if (!appContext) {
1933
+ return undefined;
1934
+ }
1935
+ const chars = JSON.stringify(appContext).length;
1936
+ if (chars > MAX_CLIENT_TOOLS_JSON_CHARS) {
1937
+ LogErrorEx({
1938
+ message:
1939
+ `RealtimeClientSessionResolver: app context for session ${sessionID} not stored — ${chars} chars ` +
1940
+ `exceeds the ${MAX_CLIENT_TOOLS_JSON_CHARS} cap; delegated runs get no app context.`,
1941
+ severity: 'warning',
1942
+ });
1943
+ return undefined;
1944
+ }
1945
+ return appContext;
1946
+ }
1947
+
1847
1948
  /**
1848
1949
  * Loads a session, enforcing inbound ownership (`UserID === contextUser.ID`) and that it is not
1849
1950
  * `Closed`. Throws on any violation. Returns the loaded session for the caller to read from.
@@ -2089,6 +2190,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2089
2190
  * as the resumed transcript are removed, so the prompt never carries two accounts of one
2090
2191
  * exchange.
2091
2192
  *
2193
+ * Only the rows on the session's path that no rerun replaced are read: the fork it started on,
2194
+ * or Main, in `Sequence` order. When that path cannot be built — its branch rows are missing or invalid — the session
2195
+ * starts with no history, never with every branch's rows.
2196
+ *
2092
2197
  * Strictly tolerant — no conversation, a failed read, or any throw yields `[]`, never a failed
2093
2198
  * session start. The call still works; it just starts cold.
2094
2199
  *
@@ -2096,6 +2201,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2096
2201
  * @param coveredSessionIDs Session ids already framed via `PriorTranscript`.
2097
2202
  * @param contextUser The calling user (RunView scope — the read is row-level-secured).
2098
2203
  * @param provider The request-scoped metadata provider.
2204
+ * @param branchId The fork the session started on; null is Main.
2099
2205
  * @returns The capped history, oldest first.
2100
2206
  */
2101
2207
  private async loadConversationHistory(
@@ -2103,12 +2209,22 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2103
2209
  coveredSessionIDs: string[],
2104
2210
  contextUser: UserInfo,
2105
2211
  provider: IMetadataProvider,
2212
+ branchId: string | null,
2106
2213
  ): Promise<ChatMessage[]> {
2107
2214
  if (!conversationId) {
2108
2215
  return [];
2109
2216
  }
2217
+ const rv = RunView.FromMetadataProvider(provider);
2218
+ let pathFilter: string;
2219
+ try {
2220
+ pathFilter = await ConversationEngine.BranchPathFilterFresh(conversationId, branchId, contextUser, provider);
2221
+ } catch (error) {
2222
+ LogError(
2223
+ `StartRealtimeClientSession: could not build the path of the session's view for conversation ${conversationId}: ${(error as Error).message}`,
2224
+ );
2225
+ return [];
2226
+ }
2110
2227
  try {
2111
- const rv = RunView.FromMetadataProvider(provider);
2112
2228
  const result = await rv.RunView<{
2113
2229
  Role: string;
2114
2230
  Message: string | null;
@@ -2117,9 +2233,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2117
2233
  }>(
2118
2234
  {
2119
2235
  EntityName: CONVERSATION_DETAIL_ENTITY,
2120
- ExtraFilter: `ConversationID='${conversationId.replace(/'/g, "''")}'`,
2236
+ ExtraFilter: ConversationEngine.LiveRowsFilter(pathFilter),
2121
2237
  Fields: ['ID', 'Role', 'Message', 'HiddenToUser', 'AgentSessionID', '__mj_CreatedAt'],
2122
- OrderBy: '__mj_CreatedAt ASC',
2238
+ OrderBy: 'Sequence ASC',
2123
2239
  ResultType: 'simple',
2124
2240
  },
2125
2241
  contextUser,
@@ -2486,6 +2602,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2486
2602
  const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, callerUser);
2487
2603
  detail.NewRecord();
2488
2604
  detail.ConversationID = session.ConversationID;
2605
+ detail.BranchID = this.sessionBranchIdOf(session);
2489
2606
  detail.Role = 'AI';
2490
2607
  detail.HiddenToUser = true;
2491
2608
  detail.Message = 'Artifacts produced during a realtime session (system anchor).';
@@ -2638,21 +2755,54 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2638
2755
 
2639
2756
  /** Shape check for one client tool declaration: Name/Description non-empty strings, ParametersSchema a plain object. */
2640
2757
  private isValidClientTool(candidate: unknown): boolean {
2641
- if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
2758
+ return IsValidRealtimeToolDefinition(candidate);
2759
+ }
2760
+
2761
+ /**
2762
+ * Tolerantly parses the channel candidates the browser reported at mint (the same caps and
2763
+ * tolerance as {@link parseClientTools}: capped, never throws, anything wrong is dropped and
2764
+ * logged). A caller that sent none yields `undefined` — an unscoped session, exactly as before.
2765
+ */
2766
+ private parseChannelCandidates(channelCandidatesJson?: string): RealtimeChannelCandidate[] | undefined {
2767
+ const parsed = ParseRealtimeChannelCandidates(channelCandidatesJson);
2768
+ for (const reason of parsed.Rejected) {
2769
+ LogError(`StartRealtimeClientSession: ${reason}.`);
2770
+ }
2771
+ return parsed.Candidates.length > 0 ? parsed.Candidates : undefined;
2772
+ }
2773
+
2774
+ /**
2775
+ * Whether a session's persisted scope allows a server-side call to name `channelName`. A session
2776
+ * with no persisted scope (it reported no candidates) is unscoped and allows everything; a scoped
2777
+ * one allows only the channels its policy put in the session.
2778
+ */
2779
+ private isChannelInSessionScope(session: MJAIAgentSessionEntity, channelName: string): boolean {
2780
+ // Read the RAW config: an absent `channels` key means an unscoped session, but anything present
2781
+ // and malformed fails CLOSED — a scope that cannot be read must not widen to "everything".
2782
+ // (`channels` is a server-authoritative key, so the owner cannot delete it to unscope a session.)
2783
+ const channels = ParseConfigObject(session.Config_)['channels'];
2784
+ if (channels === undefined) {
2785
+ return true;
2786
+ }
2787
+ if (!Array.isArray(channels)) {
2788
+ LogError(`RealtimeClientSessionResolver: session ${session.ID} has a malformed channel scope; refusing channel '${channelName}'.`);
2642
2789
  return false;
2643
2790
  }
2644
- const tool = candidate as Partial<RealtimeToolDefinition>;
2645
- return (
2646
- typeof tool.Name === 'string' && tool.Name.trim().length > 0 && tool.Name.length <= 128 &&
2647
- typeof tool.Description === 'string' && tool.Description.trim().length > 0 &&
2648
- tool.ParametersSchema !== null && typeof tool.ParametersSchema === 'object' && !Array.isArray(tool.ParametersSchema)
2649
- );
2791
+ const wanted = NormalizeChannelKey(channelName);
2792
+ return channels.some((key) => typeof key === 'string' && NormalizeChannelKey(key) === wanted);
2793
+ }
2794
+
2795
+ /** The fork the session was started on (its config's `conversationBranchID`); null is Main. */
2796
+ private sessionBranchIdOf(session: MJAIAgentSessionEntity): string | null {
2797
+ const branchId = ParseConfigObject(session.Config_)['conversationBranchID'];
2798
+ return typeof branchId === 'string' && branchId.length > 0 ? branchId : null;
2650
2799
  }
2651
2800
 
2652
2801
  /**
2653
- * Parses the session's persisted config, returning the authoritative `targetAgentID` plus any
2654
- * observability run ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed
2655
- * or carries no target — a relay cannot proceed without a target.
2802
+ * Parses the session's persisted config, returning its `targetAgentID` plus any observability run
2803
+ * ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed or carries no
2804
+ * target — a relay cannot proceed without a target. The owner can edit the config, so callers
2805
+ * re-check what they act on.
2656
2806
  */
2657
2807
  private readSessionConfig(session: MJAIAgentSessionEntity): RealtimeSessionConfig {
2658
2808
  const raw = session.Config_;
@@ -2670,11 +2820,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2670
2820
  maxSessionDeadlineIso:
2671
2821
  typeof parsed.maxSessionDeadlineIso === 'string' ? parsed.maxSessionDeadlineIso : undefined,
2672
2822
  applicationID: typeof parsed.applicationID === 'string' ? parsed.applicationID : undefined,
2823
+ appContext:
2824
+ parsed.appContext && typeof parsed.appContext === 'object' && !Array.isArray(parsed.appContext)
2825
+ ? parsed.appContext
2826
+ : undefined,
2673
2827
  allowedAgents: Array.isArray(parsed.allowedAgents) ? parsed.allowedAgents : undefined,
2674
2828
  directActions:
2675
2829
  parsed.directActions && typeof parsed.directActions === 'object' && typeof parsed.directActions.enabled === 'boolean'
2676
2830
  ? (parsed.directActions as RealtimeDirectActionsConfig)
2677
2831
  : undefined,
2832
+ conversationBranchID: typeof parsed.conversationBranchID === 'string' ? parsed.conversationBranchID : undefined,
2833
+ mediaCollectionID: typeof parsed.mediaCollectionID === 'string' ? parsed.mediaCollectionID : undefined,
2834
+ channels: Array.isArray(parsed.channels) ? parsed.channels.filter((k): k is string => typeof k === 'string') : undefined,
2678
2835
  };
2679
2836
  }
2680
2837
  } catch {
@@ -2762,6 +2919,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2762
2919
  );
2763
2920
  detail.NewRecord();
2764
2921
  detail.ConversationID = session.ConversationID;
2922
+ detail.BranchID = this.sessionBranchIdOf(session);
2765
2923
  detail.Role = this.mapTranscriptRole(role);
2766
2924
  detail.Message = text;
2767
2925
  detail.AgentSessionID = session.ID;
@@ -2935,6 +3093,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2935
3093
  );
2936
3094
  detail.NewRecord();
2937
3095
  detail.ConversationID = session.ConversationID;
3096
+ detail.BranchID = this.sessionBranchIdOf(session);
2938
3097
  detail.Role = 'AI';
2939
3098
  detail.HiddenToUser = true;
2940
3099
  detail.AgentSessionID = session.ID;