@memberjunction/server 5.51.2 → 5.51.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 (211) hide show
  1. package/README.md +34 -1
  2. package/dist/agentSessions/SessionManager.d.ts +5 -2
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +17 -8
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.js +136 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  10. package/dist/agentSessions/index.d.ts +1 -0
  11. package/dist/agentSessions/index.d.ts.map +1 -1
  12. package/dist/agentSessions/index.js +1 -0
  13. package/dist/agentSessions/index.js.map +1 -1
  14. package/dist/auth/actionAuthorization.d.ts +40 -0
  15. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  16. package/dist/auth/actionAuthorization.js +94 -0
  17. package/dist/auth/actionAuthorization.js.map +1 -0
  18. package/dist/auth/index.d.ts +1 -0
  19. package/dist/auth/index.d.ts.map +1 -1
  20. package/dist/auth/index.js +1 -0
  21. package/dist/auth/index.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +53 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +34 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts +6 -0
  31. package/dist/context.d.ts.map +1 -1
  32. package/dist/context.js +24 -34
  33. package/dist/context.js.map +1 -1
  34. package/dist/generated/generated.d.ts.map +1 -1
  35. package/dist/generated/generated.js +3262 -3273
  36. package/dist/generated/generated.js.map +1 -1
  37. package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
  38. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.js +170 -15
  40. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  41. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  42. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  43. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  44. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  45. package/dist/generic/PushStatusResolver.d.ts +58 -0
  46. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  47. package/dist/generic/PushStatusResolver.js +42 -3
  48. package/dist/generic/PushStatusResolver.js.map +1 -1
  49. package/dist/generic/ResolverBase.d.ts +148 -3
  50. package/dist/generic/ResolverBase.d.ts.map +1 -1
  51. package/dist/generic/ResolverBase.js +287 -30
  52. package/dist/generic/ResolverBase.js.map +1 -1
  53. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  54. package/dist/generic/RunViewResolver.js +12 -0
  55. package/dist/generic/RunViewResolver.js.map +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +58 -50
  58. package/dist/index.js.map +1 -1
  59. package/dist/multiTenancy/index.d.ts.map +1 -1
  60. package/dist/multiTenancy/index.js +22 -2
  61. package/dist/multiTenancy/index.js.map +1 -1
  62. package/dist/postgresqlCompat.d.ts +8 -0
  63. package/dist/postgresqlCompat.d.ts.map +1 -0
  64. package/dist/postgresqlCompat.js +19 -0
  65. package/dist/postgresqlCompat.js.map +1 -0
  66. package/dist/postgresqlPoolSettings.d.ts +108 -0
  67. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  68. package/dist/postgresqlPoolSettings.js +134 -0
  69. package/dist/postgresqlPoolSettings.js.map +1 -0
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
  71. package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
  72. package/dist/resolvers/ActionResolver.d.ts +31 -5
  73. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  74. package/dist/resolvers/ActionResolver.js +75 -14
  75. package/dist/resolvers/ActionResolver.js.map +1 -1
  76. package/dist/resolvers/AdhocQueryResolver.d.ts +39 -17
  77. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  78. package/dist/resolvers/AdhocQueryResolver.js +97 -157
  79. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  80. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  81. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  82. package/dist/resolvers/DatasetResolver.js +57 -8
  83. package/dist/resolvers/DatasetResolver.js.map +1 -1
  84. package/dist/resolvers/EntityRecordNameResolver.d.ts +14 -4
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  86. package/dist/resolvers/EntityRecordNameResolver.js +18 -6
  87. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  88. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  89. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  90. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  91. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  92. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataResolver.js +13 -1
  94. package/dist/resolvers/GetDataResolver.js.map +1 -1
  95. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  96. package/dist/resolvers/MCPResolver.js +13 -16
  97. package/dist/resolvers/MCPResolver.js.map +1 -1
  98. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
  99. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  100. package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
  101. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  102. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  103. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  104. package/dist/resolvers/RemoteBrowserActionResolver.js +24 -27
  105. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  106. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  108. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  109. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunTemplateResolver.js +12 -11
  111. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  112. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTestResolver.js +90 -112
  114. package/dist/resolvers/RunTestResolver.js.map +1 -1
  115. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  116. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  117. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  118. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  119. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  120. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  121. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  122. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  123. package/dist/resolvers/adhocTimeout.js +12 -0
  124. package/dist/resolvers/adhocTimeout.js.map +1 -0
  125. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  126. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  127. package/dist/rest/ViewOperationsHandler.js +29 -17
  128. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  129. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  130. package/dist/services/TaskOrchestrator.js +17 -15
  131. package/dist/services/TaskOrchestrator.js.map +1 -1
  132. package/package.json +90 -89
  133. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  134. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  135. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  136. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  137. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  138. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  139. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  140. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  141. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +136 -0
  142. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
  143. package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
  144. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
  145. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
  146. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
  147. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  148. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
  149. package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
  150. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  151. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
  152. package/src/__tests__/SessionManager.test.ts +88 -13
  153. package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
  154. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  155. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
  156. package/src/__tests__/actionAuthorization.test.ts +130 -0
  157. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  158. package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
  159. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
  160. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  161. package/src/__tests__/multiTenancy.test.ts +1 -1
  162. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  163. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  164. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  165. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  166. package/src/__tests__/resolverBase.rls.test.ts +2 -0
  167. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  168. package/src/agentSessions/SessionManager.ts +24 -25
  169. package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
  170. package/src/agentSessions/index.ts +1 -0
  171. package/src/auth/actionAuthorization.ts +119 -0
  172. package/src/auth/index.ts +1 -0
  173. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  174. package/src/config.ts +34 -1
  175. package/src/context.ts +25 -34
  176. package/src/generated/generated.ts +3262 -3273
  177. package/src/generic/CacheInvalidationResolver.ts +206 -15
  178. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  179. package/src/generic/PushStatusResolver.ts +79 -4
  180. package/src/generic/ResolverBase.ts +339 -31
  181. package/src/generic/RunViewResolver.ts +17 -0
  182. package/src/index.ts +65 -52
  183. package/src/multiTenancy/index.ts +24 -2
  184. package/src/postgresqlCompat.ts +21 -0
  185. package/src/postgresqlPoolSettings.ts +213 -0
  186. package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
  187. package/src/resolvers/ActionResolver.ts +85 -22
  188. package/src/resolvers/AdhocQueryResolver.ts +110 -177
  189. package/src/resolvers/DatasetResolver.ts +70 -10
  190. package/src/resolvers/EntityRecordNameResolver.ts +24 -7
  191. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  192. package/src/resolvers/GetDataResolver.ts +14 -1
  193. package/src/resolvers/MCPResolver.ts +13 -15
  194. package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
  195. package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
  196. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  197. package/src/resolvers/RunTemplateResolver.ts +13 -12
  198. package/src/resolvers/RunTestResolver.ts +90 -112
  199. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  200. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  201. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  202. package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
  203. package/src/resolvers/adhocTimeout.ts +12 -0
  204. package/src/rest/ViewOperationsHandler.ts +37 -18
  205. package/src/services/TaskOrchestrator.ts +17 -17
  206. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  207. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  208. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  209. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  210. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  211. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -0,0 +1,318 @@
1
+ /**
2
+ * A realtime session's `Config` JSON is writable by the session owner: the UI and Widget Guest
3
+ * roles hold Create/Update on `MJ: AI Agent Sessions`, row-scoped only to their own sessions. These
4
+ * tests edit `Config` the way such an owner could and check that the relay mutations do not take
5
+ * authority from it: the target agent is re-authorized for the caller, and run ids are used only
6
+ * when they are records of the session.
7
+ */
8
+ // type-graphql decorators on the resolver need the reflect-metadata polyfill loaded first.
9
+ import 'reflect-metadata';
10
+
11
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
12
+
13
+ const hasPermissionMock = vi.fn(async (_agentID: string, _user?: unknown, _permission?: string): Promise<boolean> => true);
14
+ vi.mock('@memberjunction/ai-engine-base', () => ({
15
+ AIAgentPermissionHelper: {
16
+ HasPermission: (agentID: string, user: unknown, permission: string) => hasPermissionMock(agentID, user, permission),
17
+ },
18
+ AIEngineBase: {
19
+ GetProviderInstance: vi.fn(),
20
+ },
21
+ }));
22
+
23
+ vi.mock('@memberjunction/aiengine', () => ({
24
+ AIEngine: {
25
+ Instance: {
26
+ Config: vi.fn(async () => undefined),
27
+ Agents: [],
28
+ AgentTypes: [],
29
+ },
30
+ },
31
+ }));
32
+
33
+ const executeRelayedToolMock = vi.fn();
34
+ const appendPromptRunMessageMock = vi.fn<RealtimeClientSessionService['AppendPromptRunMessage']>(async () => true);
35
+ const accumulatePromptRunUsageMock = vi.fn<RealtimeClientSessionService['AccumulatePromptRunUsage']>(async () => true);
36
+ vi.mock('@memberjunction/ai-agents', async (importOriginal) => {
37
+ const actual = await importOriginal<Record<string, unknown>>();
38
+ return {
39
+ ...actual,
40
+ RealtimeClientSessionService: class {
41
+ ExecuteRelayedTool = executeRelayedToolMock;
42
+ AppendPromptRunMessage = appendPromptRunMessageMock;
43
+ AccumulatePromptRunUsage = accumulatePromptRunUsageMock;
44
+ },
45
+ };
46
+ });
47
+
48
+ const heartbeatMock = vi.fn(async () => true);
49
+ vi.mock('../agentSessions/index.js', () => ({
50
+ SessionManager: class {
51
+ Heartbeat = heartbeatMock;
52
+ },
53
+ }));
54
+
55
+ let currentProvider: unknown;
56
+ vi.mock('../util.js', () => ({
57
+ GetReadWriteProvider: () => currentProvider,
58
+ }));
59
+
60
+ const getSystemUserMock = vi.fn<() => UserInfo | undefined>();
61
+ vi.mock('@memberjunction/sqlserver-dataprovider', async (importOriginal) => {
62
+ const actual = await importOriginal<Record<string, unknown>>();
63
+ return {
64
+ ...actual,
65
+ UserCache: {
66
+ get Instance() {
67
+ return { GetSystemUser: getSystemUserMock };
68
+ },
69
+ },
70
+ };
71
+ });
72
+
73
+ import { RealtimeClientSessionResolver } from '../resolvers/RealtimeClientSessionResolver.js';
74
+ import type { AppContext } from '../types.js';
75
+ import { IsPlatformSQL, type RunViewParams, type RunViewResult, type UserInfo } from '@memberjunction/core';
76
+ import type { ExecuteRelayedToolInput, RealtimeClientSessionService } from '@memberjunction/ai-agents';
77
+ import type { PubSubEngine } from 'type-graphql';
78
+
79
+ const SESSION_ID = 'c3c3c3c3-0000-4000-8000-000000000001';
80
+ const USER_ID = 'c3c3c3c3-0000-4000-8000-000000000002';
81
+ const TARGET_ID = 'c3c3c3c3-0000-4000-8000-000000000003';
82
+ const OTHER_AGENT_ID = 'c3c3c3c3-0000-4000-8000-000000000004';
83
+ const CO_RUN_ID = 'c3c3c3c3-0000-4000-8000-000000000005';
84
+ const PROMPT_RUN_ID = 'c3c3c3c3-0000-4000-8000-000000000006';
85
+ const STEP_ID = 'c3c3c3c3-0000-4000-8000-000000000007';
86
+ const PAUSED_RUN_ID = 'c3c3c3c3-0000-4000-8000-000000000008';
87
+ /** A run that exists, but belongs to someone else's session. */
88
+ const FOREIGN_RUN_ID = 'c3c3c3c3-0000-4000-8000-0000000000f1';
89
+ /** Harmless text that is not a UUID and would change the meaning of a quoted filter. */
90
+ const NOT_A_UUID = "x' OR '1'='1";
91
+
92
+ const USER = { ID: USER_ID, Email: 'owner@example.com' };
93
+
94
+ interface FakeEntity {
95
+ [key: string]: unknown;
96
+ Save: () => Promise<boolean>;
97
+ Load: (id: string) => Promise<boolean>;
98
+ NewRecord: () => void;
99
+ }
100
+
101
+ /** Rows the fake RunView returns, keyed by entity name. */
102
+ type FakeRows = Record<string, Array<Record<string, unknown>>>;
103
+
104
+ /** The fake provider's `RunView`: it returns the rows for the requested entity. */
105
+ type FakeRunView = (params: RunViewParams) => Promise<RunViewResult<Record<string, unknown>>>;
106
+
107
+ function makeSession(config: Record<string, unknown>, overrides: Partial<FakeEntity> = {}): FakeEntity {
108
+ return {
109
+ ID: SESSION_ID,
110
+ Status: 'Active',
111
+ UserID: USER_ID,
112
+ ConversationID: 'c3c3c3c3-0000-4000-8000-0000000000c1',
113
+ Config_: JSON.stringify(config),
114
+ Save: vi.fn(async () => true),
115
+ Load: vi.fn(async () => true),
116
+ NewRecord: vi.fn(),
117
+ LatestResult: null,
118
+ ...overrides,
119
+ };
120
+ }
121
+
122
+ function makeDetail(): FakeEntity {
123
+ return { ID: 'c3c3c3c3-0000-4000-8000-0000000000d1', Save: vi.fn(async () => true), Load: vi.fn(async () => true), NewRecord: vi.fn() };
124
+ }
125
+
126
+ /** A provider serving the session plus view rows per entity name; `runView` records every view run. */
127
+ function makeProvider(session: FakeEntity, rows: FakeRows = {}): { runView: Mock<FakeRunView> } {
128
+ const runView = vi.fn<FakeRunView>(async (params) => {
129
+ const results = rows[params.EntityName ?? ''] ?? [];
130
+ return { Success: true, Results: results, RowCount: results.length, TotalRowCount: results.length, ExecutionTime: 0, ErrorMessage: '' };
131
+ });
132
+ currentProvider = {
133
+ GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: AI Agent Sessions' ? session : makeDetail())),
134
+ RunView: runView,
135
+ RunViews: vi.fn(async (paramsList: RunViewParams[]) => Promise.all(paramsList.map((params) => runView(params)))),
136
+ };
137
+ return { runView };
138
+ }
139
+
140
+ /** Rows that make every run id in `ids` a record of this session. */
141
+ function sessionRuns(...ids: string[]): FakeRows {
142
+ return { 'MJ: AI Agent Runs': ids.map((ID) => ({ ID })) };
143
+ }
144
+
145
+ function makeResolver(user: object = USER): RealtimeClientSessionResolver {
146
+ const resolver = new RealtimeClientSessionResolver();
147
+ (resolver as unknown as { GetUserFromPayload: () => unknown }).GetUserFromPayload = () => user;
148
+ return resolver;
149
+ }
150
+
151
+ function makeCtx(): AppContext {
152
+ return { userPayload: { sessionId: 'c3-pubsub-session' }, providers: [] } as unknown as AppContext;
153
+ }
154
+
155
+ function makePubSub(): PubSubEngine {
156
+ return { publish: vi.fn(async () => undefined) } as unknown as PubSubEngine;
157
+ }
158
+
159
+ function relay(resolver: RealtimeClientSessionResolver): Promise<string> {
160
+ return relayTool(resolver, 'invoke-target-agent', '{"request":"hi"}');
161
+ }
162
+
163
+ function relayTool(resolver: RealtimeClientSessionResolver, toolName: string, argsJson = '{}'): Promise<string> {
164
+ return resolver.ExecuteRealtimeSessionTool(SESSION_ID, 'call-1', toolName, argsJson, makeCtx(), makePubSub());
165
+ }
166
+
167
+ /** A scoped anonymous magic-link caller: the relay runs its work as the system user. */
168
+ const SCOPED_ANONYMOUS = { ID: USER_ID, Email: 'guest@magic-link.local', IsMagicLinkAnonymous: true, MagicLinkScope: { ResourceID: 'c3-scope' } };
169
+ /** A public web-widget guest: anonymous, but not elevated. */
170
+ const WIDGET_GUEST = { ID: USER_ID, Email: 'guest@widget.local', IsMagicLinkAnonymous: true, WidgetGuestContext: { WidgetID: 'c3c3c3c3-0000-4000-8000-0000000000b1' } };
171
+ /** A resource-scoped magic-link session: a normal role pinned to one shared resource. */
172
+ const RESOURCE_SCOPED = { ID: USER_ID, Email: 'invitee@example.com', MagicLinkScope: { ResourceID: 'c3-shared-resource' } };
173
+ const SYSTEM_USER = { ID: 'c3c3c3c3-0000-4000-8000-0000000000aa', Email: 'system@example.com' } as UserInfo;
174
+
175
+ function relayInput(): ExecuteRelayedToolInput {
176
+ return executeRelayedToolMock.mock.calls[0][0] as ExecuteRelayedToolInput;
177
+ }
178
+
179
+ /** The SQL text of each filter the fake RunView received, with every variant of a platform-specific filter. */
180
+ function issuedFilters(runView: Mock<FakeRunView>): string[] {
181
+ return runView.mock.calls.map(([params]) => {
182
+ const filter = params.ExtraFilter;
183
+ return IsPlatformSQL(filter) ? [filter.default, filter.sqlserver, filter.postgresql].join('\n') : (filter ?? '');
184
+ });
185
+ }
186
+
187
+ beforeEach(() => {
188
+ hasPermissionMock.mockReset();
189
+ hasPermissionMock.mockImplementation(async () => true);
190
+ executeRelayedToolMock.mockReset();
191
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
192
+ appendPromptRunMessageMock.mockReset();
193
+ appendPromptRunMessageMock.mockResolvedValue(true);
194
+ accumulatePromptRunUsageMock.mockReset();
195
+ accumulatePromptRunUsageMock.mockResolvedValue(true);
196
+ heartbeatMock.mockClear();
197
+ getSystemUserMock.mockReset();
198
+ getSystemUserMock.mockReturnValue(undefined);
199
+ });
200
+
201
+ describe('ExecuteRealtimeSessionTool — target agent named in Config', () => {
202
+ it('refuses to relay when the caller cannot run the target agent in the session config', async () => {
203
+ hasPermissionMock.mockImplementation(async (agentID: string) => agentID !== OTHER_AGENT_ID);
204
+ makeProvider(makeSession({ targetAgentID: OTHER_AGENT_ID }));
205
+
206
+ await expect(relay(makeResolver())).rejects.toThrow(/not authorized/i);
207
+ expect(executeRelayedToolMock).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it('refuses before elevating a scoped anonymous caller to the system user', async () => {
211
+ getSystemUserMock.mockReturnValue(SYSTEM_USER);
212
+ hasPermissionMock.mockImplementation(async (agentID: string) => agentID !== OTHER_AGENT_ID);
213
+ makeProvider(makeSession({ targetAgentID: OTHER_AGENT_ID }));
214
+
215
+ await expect(relay(makeResolver(SCOPED_ANONYMOUS))).rejects.toThrow(/not authorized/i);
216
+ expect(executeRelayedToolMock).not.toHaveBeenCalled();
217
+ // CanRun was asked about the caller, never the system user.
218
+ expect(hasPermissionMock).toHaveBeenCalledWith(OTHER_AGENT_ID, SCOPED_ANONYMOUS, 'run');
219
+ expect(hasPermissionMock.mock.calls.every(([, user]) => user === SCOPED_ANONYMOUS)).toBe(true);
220
+ });
221
+
222
+ it('relays when the caller can run the target agent', async () => {
223
+ makeProvider(makeSession({ targetAgentID: TARGET_ID }));
224
+
225
+ await expect(relay(makeResolver())).resolves.toBe('{"ok":true}');
226
+ expect(relayInput().TargetAgentID).toBe(TARGET_ID);
227
+ });
228
+ });
229
+
230
+ describe('ExecuteRealtimeSessionTool — run ids in Config', () => {
231
+ it('drops run ids that are not UUIDs, and never puts them in a filter', async () => {
232
+ const { runView } = makeProvider(makeSession({ targetAgentID: TARGET_ID, coAgentRunID: NOT_A_UUID, pendingFeedbackRunID: NOT_A_UUID }));
233
+
234
+ await relay(makeResolver());
235
+
236
+ expect(relayInput().ParentRunID).toBeUndefined();
237
+ expect(relayInput().ResumeRunID).toBeUndefined();
238
+ expect(issuedFilters(runView).filter((f) => f.includes(NOT_A_UUID))).toEqual([]);
239
+ });
240
+
241
+ it('drops run ids that are not records of this session', async () => {
242
+ const { runView } = makeProvider(
243
+ makeSession({ targetAgentID: TARGET_ID, coAgentRunID: FOREIGN_RUN_ID, pendingFeedbackRunID: FOREIGN_RUN_ID }),
244
+ );
245
+
246
+ await relay(makeResolver());
247
+
248
+ expect(relayInput().ParentRunID).toBeUndefined();
249
+ expect(relayInput().ResumeRunID).toBeUndefined();
250
+ expect(issuedFilters(runView).some((f) => f.includes(`AgentSessionID='${SESSION_ID}'`))).toBe(true);
251
+ });
252
+
253
+ it('passes run ids that are records of this session', async () => {
254
+ makeProvider(
255
+ makeSession({ targetAgentID: TARGET_ID, coAgentRunID: CO_RUN_ID, pendingFeedbackRunID: PAUSED_RUN_ID }),
256
+ sessionRuns(CO_RUN_ID, PAUSED_RUN_ID),
257
+ );
258
+
259
+ await relay(makeResolver());
260
+
261
+ expect(relayInput().ParentRunID).toBe(CO_RUN_ID);
262
+ expect(relayInput().ResumeRunID).toBe(PAUSED_RUN_ID);
263
+ });
264
+
265
+ it('stops passing run ids that stopped being records of this session since the last relay', async () => {
266
+ const rows = sessionRuns(CO_RUN_ID, PAUSED_RUN_ID);
267
+ makeProvider(makeSession({ targetAgentID: TARGET_ID, coAgentRunID: CO_RUN_ID, pendingFeedbackRunID: PAUSED_RUN_ID }), rows);
268
+ // The delegated run pauses again, so the session config still names it on the next relay.
269
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true, PausedRunID: PAUSED_RUN_ID });
270
+ const resolver = makeResolver();
271
+
272
+ await relay(resolver);
273
+ // Both runs are moved to another session between the relays.
274
+ rows['MJ: AI Agent Runs'] = [];
275
+ await relay(resolver);
276
+
277
+ const [first, second] = executeRelayedToolMock.mock.calls.map(([input]) => input as ExecuteRelayedToolInput);
278
+ expect(first.ParentRunID).toBe(CO_RUN_ID);
279
+ expect(first.ResumeRunID).toBe(PAUSED_RUN_ID);
280
+ expect(second.ParentRunID).toBeUndefined();
281
+ expect(second.ResumeRunID).toBeUndefined();
282
+ });
283
+ });
284
+
285
+ describe('prompt-run mirrors — promptRunID in Config', () => {
286
+ const PROMPT_STEP_ROWS: FakeRows = {
287
+ ...sessionRuns(CO_RUN_ID),
288
+ 'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
289
+ };
290
+
291
+ it('RelayRealtimeUsage does not write to a prompt run that is not this session\'s', async () => {
292
+ makeProvider(makeSession({ targetAgentID: TARGET_ID, coAgentRunID: CO_RUN_ID, promptRunID: FOREIGN_RUN_ID }), sessionRuns(CO_RUN_ID));
293
+
294
+ const out = await makeResolver().RelayRealtimeUsage(SESSION_ID, 10, 5, makeCtx());
295
+
296
+ expect(out).toBe(false);
297
+ expect(accumulatePromptRunUsageMock).not.toHaveBeenCalled();
298
+ });
299
+
300
+ it('RelayRealtimeTranscript does not append to a prompt run that is not this session\'s', async () => {
301
+ makeProvider(makeSession({ targetAgentID: TARGET_ID, coAgentRunID: CO_RUN_ID, promptRunID: FOREIGN_RUN_ID }), sessionRuns(CO_RUN_ID));
302
+
303
+ const out = await makeResolver().RelayRealtimeTranscript(SESSION_ID, 'User', 'hello', makeCtx());
304
+
305
+ expect(out).toBe(true);
306
+ expect(appendPromptRunMessageMock).not.toHaveBeenCalled();
307
+ });
308
+
309
+ it('RelayRealtimeUsage writes to the prompt run of this session\'s co-agent run', async () => {
310
+ makeProvider(makeSession({ targetAgentID: TARGET_ID, coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }), PROMPT_STEP_ROWS);
311
+
312
+ const out = await makeResolver().RelayRealtimeUsage(SESSION_ID, 10, 5, makeCtx());
313
+
314
+ expect(out).toBe(true);
315
+ expect(accumulatePromptRunUsageMock).toHaveBeenCalledTimes(1);
316
+ expect(accumulatePromptRunUsageMock.mock.calls[0][0]).toBe(PROMPT_RUN_ID);
317
+ });
318
+ });
@@ -227,13 +227,44 @@ function makeSessionEntity(overrides: Partial<FakeSession> = {}): FakeSession {
227
227
  };
228
228
  }
229
229
 
230
- /** Provider whose GetEntityObject returns a fixed entity per entity name. */
231
- function makeProvider(factory: (entityName: string) => FakeSession): unknown {
232
- return { GetEntityObject: vi.fn(async (name: string) => factory(name)) };
230
+ /** Co-agent observability and paused-run ids, as a voice session's Config holds them. */
231
+ const CO_RUN_ID = 'c0c0c0c0-0000-4000-8000-000000000001';
232
+ const PROMPT_RUN_ID = 'c0c0c0c0-0000-4000-8000-000000000002';
233
+ const PAUSED_RUN_ID = 'c0c0c0c0-0000-4000-8000-000000000003';
234
+
235
+ /** `RunView`/`RunViews` stubs for a provider. */
236
+ interface RunLookups {
237
+ RunView: ReturnType<typeof vi.fn>;
238
+ RunViews: ReturnType<typeof vi.fn>;
239
+ }
240
+
241
+ /**
242
+ * Answers the session run-id lookups: each id in `runIDs` is a run of the session, and
243
+ * `promptRunID` (when given) is the TargetLogID of the co-agent run's Prompt step.
244
+ */
245
+ function sessionRunLookups(runIDs: string[], promptRunID?: string): RunLookups {
246
+ const runView = vi.fn(async (params: { EntityName?: string }) => {
247
+ if (params.EntityName === 'MJ: AI Agent Runs') {
248
+ return { Success: true, Results: runIDs.map((ID) => ({ ID })) };
249
+ }
250
+ if (params.EntityName === 'MJ: AI Agent Run Steps' && promptRunID) {
251
+ return { Success: true, Results: [{ ID: 'c0c0c0c0-0000-4000-8000-0000000000a1', StepType: 'Prompt', TargetLogID: promptRunID }] };
252
+ }
253
+ return { Success: true, Results: [] };
254
+ });
255
+ const runViews = vi.fn(async (paramsList: Array<{ EntityName?: string }>) => Promise.all(paramsList.map((params) => runView(params))));
256
+ return { RunView: runView, RunViews: runViews };
257
+ }
258
+
259
+ /** Provider whose GetEntityObject returns a fixed entity per entity name, plus optional run-id lookups. */
260
+ function makeProvider(factory: (entityName: string) => FakeSession, lookups?: RunLookups): unknown {
261
+ return { GetEntityObject: vi.fn(async (name: string) => factory(name)), ...lookups };
233
262
  }
234
263
 
235
264
  beforeEach(() => {
236
265
  hasPermissionMock.mockReset();
266
+ // Callers may run the agents in play unless a test says otherwise; relays re-check CanRun on the target.
267
+ hasPermissionMock.mockResolvedValue(true);
237
268
  engineCoAgentsMock.mockReset();
238
269
  engineCoAgentsMock.mockReturnValue([]);
239
270
  engineChannelsMock.mockReset();
@@ -670,10 +701,12 @@ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
670
701
  });
671
702
 
672
703
  it('reads the target + co-agent run id from the session config and returns ResultJson', async () => {
673
- currentProvider = makeProvider(() =>
674
- makeSessionEntity({
675
- Config_: JSON.stringify({ targetAgentID: 'target-from-session', coAgentRunID: 'co-run-77' }),
676
- }),
704
+ currentProvider = makeProvider(
705
+ () =>
706
+ makeSessionEntity({
707
+ Config_: JSON.stringify({ targetAgentID: 'target-from-session', coAgentRunID: CO_RUN_ID }),
708
+ }),
709
+ sessionRunLookups([CO_RUN_ID]),
677
710
  );
678
711
  executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
679
712
  const resolver = makeResolver();
@@ -696,7 +729,7 @@ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
696
729
  // Target comes from the session, NOT the client.
697
730
  expect(relayArg.TargetAgentID).toBe('target-from-session');
698
731
  // Delegated run nests under the co-agent observability run from the session config.
699
- expect(relayArg.ParentRunID).toBe('co-run-77');
732
+ expect(relayArg.ParentRunID).toBe(CO_RUN_ID);
700
733
  expect(relayArg.Call).toEqual({ CallID: 'call-1', ToolName: 'invoke-target-agent', Arguments: '{"request":"do it"}' });
701
734
  expect(heartbeatMock).toHaveBeenCalledWith('session-1', USER, currentProvider);
702
735
  });
@@ -760,9 +793,9 @@ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
760
793
 
761
794
  it('consumes pendingFeedbackRunID as ResumeRunID and clears it on the next call', async () => {
762
795
  const session = makeSessionEntity({
763
- Config_: JSON.stringify({ targetAgentID: 'target-1', pendingFeedbackRunID: 'paused-1' }),
796
+ Config_: JSON.stringify({ targetAgentID: 'target-1', pendingFeedbackRunID: PAUSED_RUN_ID }),
764
797
  });
765
- currentProvider = makeProvider(() => session);
798
+ currentProvider = makeProvider(() => session, sessionRunLookups([PAUSED_RUN_ID]));
766
799
  // Resumed run completes (no new pause).
767
800
  executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"done":1}', Success: true });
768
801
  const resolver = makeResolver();
@@ -771,7 +804,7 @@ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
771
804
 
772
805
  // The paused id was passed in to resume the run...
773
806
  const relayArg = executeRelayedToolMock.mock.calls[0][0] as { ResumeRunID?: string };
774
- expect(relayArg.ResumeRunID).toBe('paused-1');
807
+ expect(relayArg.ResumeRunID).toBe(PAUSED_RUN_ID);
775
808
  // ...and cleared from the session config afterward.
776
809
  expect(JSON.parse(session.Config_ as string).pendingFeedbackRunID).toBeUndefined();
777
810
  expect(session.Save).toHaveBeenCalled();
@@ -779,16 +812,16 @@ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
779
812
 
780
813
  it('re-stores a new pendingFeedbackRunID when a resumed run pauses again', async () => {
781
814
  const session = makeSessionEntity({
782
- Config_: JSON.stringify({ targetAgentID: 'target-1', pendingFeedbackRunID: 'paused-1' }),
815
+ Config_: JSON.stringify({ targetAgentID: 'target-1', pendingFeedbackRunID: PAUSED_RUN_ID }),
783
816
  });
784
- currentProvider = makeProvider(() => session);
817
+ currentProvider = makeProvider(() => session, sessionRunLookups([PAUSED_RUN_ID]));
785
818
  executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"q":2}', Success: true, PausedRunID: 'paused-2' });
786
819
  const resolver = makeResolver();
787
820
 
788
821
  await resolver.ExecuteRealtimeSessionTool('session-1', 'call-3', 'invoke-target-agent', '{"request":"yes"}', makeCtx(), makePubSub());
789
822
 
790
823
  const relayArg = executeRelayedToolMock.mock.calls[0][0] as { ResumeRunID?: string };
791
- expect(relayArg.ResumeRunID).toBe('paused-1');
824
+ expect(relayArg.ResumeRunID).toBe(PAUSED_RUN_ID);
792
825
  expect(JSON.parse(session.Config_ as string).pendingFeedbackRunID).toBe('paused-2');
793
826
  });
794
827
 
@@ -2192,10 +2225,11 @@ describe('RealtimeClientSessionResolver.RelayRealtimeUsage', () => {
2192
2225
  }): { provider: unknown; promptRun: FakeSession } {
2193
2226
  const session =
2194
2227
  opts.session ??
2195
- makeSessionEntity({ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }) });
2196
- const promptRun = opts.promptRun ?? makeSessionEntity({ ID: 'prompt-run-1', TokensPrompt: null, TokensCompletion: null, TokensUsed: null });
2228
+ makeSessionEntity({ Config_: JSON.stringify({ targetAgentID: 'target-1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }) });
2229
+ const promptRun = opts.promptRun ?? makeSessionEntity({ ID: PROMPT_RUN_ID, TokensPrompt: null, TokensCompletion: null, TokensUsed: null });
2197
2230
  const provider = {
2198
2231
  GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: AI Prompt Runs' ? promptRun : session)),
2232
+ ...sessionRunLookups([CO_RUN_ID], PROMPT_RUN_ID),
2199
2233
  };
2200
2234
  return { provider, promptRun };
2201
2235
  }
@@ -2221,7 +2255,7 @@ describe('RealtimeClientSessionResolver.RelayRealtimeUsage', () => {
2221
2255
  const ok = await resolver.RelayRealtimeUsage('session-1', 30, 5, makeCtx());
2222
2256
 
2223
2257
  expect(ok).toBe(true);
2224
- expect(promptRun.Load).toHaveBeenCalledWith('prompt-run-1');
2258
+ expect(promptRun.Load).toHaveBeenCalledWith(PROMPT_RUN_ID);
2225
2259
  expect(promptRun.TokensPrompt).toBe(130);
2226
2260
  expect(promptRun.TokensCompletion).toBe(45);
2227
2261
  expect(promptRun.TokensUsed).toBe(175);
@@ -2318,7 +2352,7 @@ describe('RealtimeClientSessionResolver.RelayRealtimeUsage', () => {
2318
2352
  const { provider, promptRun } = makeUsageProvider({
2319
2353
  session: makeSessionEntity({
2320
2354
  Status: 'Closed',
2321
- Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
2355
+ Config_: JSON.stringify({ targetAgentID: 'target-1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
2322
2356
  }),
2323
2357
  });
2324
2358
  currentProvider = provider;
@@ -2808,11 +2842,12 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
2808
2842
  it('accumulates relayed usage onto the prompt run under the SYSTEM user', async () => {
2809
2843
  const session = makeSessionEntity({
2810
2844
  UserID: 'anon-1',
2811
- Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
2845
+ Config_: JSON.stringify({ targetAgentID: 'target-1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
2812
2846
  });
2813
- const promptRun = makeSessionEntity({ ID: 'prompt-run-1', TokensPrompt: null, TokensCompletion: null, TokensUsed: null });
2847
+ const promptRun = makeSessionEntity({ ID: PROMPT_RUN_ID, TokensPrompt: null, TokensCompletion: null, TokensUsed: null });
2814
2848
  currentProvider = {
2815
2849
  GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: AI Prompt Runs' ? promptRun : session)),
2850
+ ...sessionRunLookups([CO_RUN_ID], PROMPT_RUN_ID),
2816
2851
  };
2817
2852
  const resolver = makeAnonResolver();
2818
2853
 
@@ -2827,9 +2862,9 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
2827
2862
  it('mirrors the transcript turn onto the prompt run as SYSTEM while the Conversation Detail stays the caller', async () => {
2828
2863
  const session = makeSessionEntity({
2829
2864
  UserID: 'anon-1',
2830
- Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
2865
+ Config_: JSON.stringify({ targetAgentID: 'target-1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
2831
2866
  });
2832
- const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
2867
+ const promptRun = makeSessionEntity({ ID: PROMPT_RUN_ID, Messages: null });
2833
2868
  const detail = makeSessionEntity({ ID: 'detail-1' });
2834
2869
  currentProvider = {
2835
2870
  GetEntityObject: vi.fn(async (name: string) => {
@@ -2837,6 +2872,7 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
2837
2872
  if (name === 'MJ: Conversation Details') return detail;
2838
2873
  return session;
2839
2874
  }),
2875
+ ...sessionRunLookups([CO_RUN_ID], PROMPT_RUN_ID),
2840
2876
  };
2841
2877
  const resolver = makeAnonResolver();
2842
2878
 
@@ -2853,11 +2889,12 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
2853
2889
  it('records the co-agent tool turn (RelayRealtimeToolTurn) under the SYSTEM user', async () => {
2854
2890
  const session = makeSessionEntity({
2855
2891
  UserID: 'anon-1',
2856
- Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
2892
+ Config_: JSON.stringify({ targetAgentID: 'target-1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
2857
2893
  });
2858
- const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
2894
+ const promptRun = makeSessionEntity({ ID: PROMPT_RUN_ID, Messages: null });
2859
2895
  currentProvider = {
2860
2896
  GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: AI Prompt Runs' ? promptRun : session)),
2897
+ ...sessionRunLookups([CO_RUN_ID], PROMPT_RUN_ID),
2861
2898
  };
2862
2899
  const resolver = makeAnonResolver();
2863
2900
 
@@ -3070,7 +3107,8 @@ describe('RealtimeClientSessionResolver — colleague authorization (allowedAgen
3070
3107
 
3071
3108
  it('excludes every colleague when the caller can run none of them', async () => {
3072
3109
  currentProvider = makeProvider(() => makeMultiTargetSession('user-1'));
3073
- hasPermissionMock.mockResolvedValue(false);
3110
+ // The caller can run the lead target, and none of the colleagues.
3111
+ hasPermissionMock.mockImplementation((...args: unknown[]) => Promise.resolve(args[0] === 'target-1'));
3074
3112
 
3075
3113
  await makeResolver().ExecuteRealtimeSessionTool(
3076
3114
  'session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub(),
@@ -3079,7 +3117,7 @@ describe('RealtimeClientSessionResolver — colleague authorization (allowedAgen
3079
3117
  expect(dispatchedAgentIDs()).toEqual([]);
3080
3118
  });
3081
3119
 
3082
- it('passes a single-target session through with no permission checks', async () => {
3120
+ it('passes a single-target session through with only the target permission check', async () => {
3083
3121
  currentProvider = makeProvider(() => makeSessionEntity());
3084
3122
  hasPermissionMock.mockResolvedValue(true);
3085
3123
 
@@ -3089,6 +3127,7 @@ describe('RealtimeClientSessionResolver — colleague authorization (allowedAgen
3089
3127
 
3090
3128
  const input = executeRelayedToolMock.mock.calls[0][0] as { AllowedAgents?: unknown };
3091
3129
  expect(input.AllowedAgents).toBeUndefined();
3092
- expect(hasPermissionMock).not.toHaveBeenCalled();
3130
+ expect(hasPermissionMock).toHaveBeenCalledTimes(1);
3131
+ expect((hasPermissionMock.mock.calls[0] as unknown[])[0]).toBe('target-1');
3093
3132
  });
3094
3133
  });