@memberjunction/server 5.40.2 → 5.41.0

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 (83) hide show
  1. package/dist/agentSessions/HostInstance.d.ts +19 -0
  2. package/dist/agentSessions/HostInstance.d.ts.map +1 -0
  3. package/dist/agentSessions/HostInstance.js +48 -0
  4. package/dist/agentSessions/HostInstance.js.map +1 -0
  5. package/dist/agentSessions/SessionJanitor.d.ts +97 -0
  6. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -0
  7. package/dist/agentSessions/SessionJanitor.js +222 -0
  8. package/dist/agentSessions/SessionJanitor.js.map +1 -0
  9. package/dist/agentSessions/SessionManager.d.ts +142 -0
  10. package/dist/agentSessions/SessionManager.d.ts.map +1 -0
  11. package/dist/agentSessions/SessionManager.js +308 -0
  12. package/dist/agentSessions/SessionManager.js.map +1 -0
  13. package/dist/agentSessions/index.d.ts +4 -0
  14. package/dist/agentSessions/index.d.ts.map +1 -0
  15. package/dist/agentSessions/index.js +26 -0
  16. package/dist/agentSessions/index.js.map +1 -0
  17. package/dist/auth/initializeProviders.d.ts.map +1 -1
  18. package/dist/auth/initializeProviders.js +6 -1
  19. package/dist/auth/initializeProviders.js.map +1 -1
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +41 -7
  22. package/dist/context.js.map +1 -1
  23. package/dist/generated/generated.d.ts +659 -3
  24. package/dist/generated/generated.d.ts.map +1 -1
  25. package/dist/generated/generated.js +5459 -1759
  26. package/dist/generated/generated.js.map +1 -1
  27. package/dist/generic/ResolverBase.js +1 -1
  28. package/dist/generic/ResolverBase.js.map +1 -1
  29. package/dist/generic/RunViewResolver.js +9 -10
  30. package/dist/generic/RunViewResolver.js.map +1 -1
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +97 -41
  34. package/dist/index.js.map +1 -1
  35. package/dist/logging/StartupLogger.d.ts +121 -0
  36. package/dist/logging/StartupLogger.d.ts.map +1 -0
  37. package/dist/logging/StartupLogger.js +245 -0
  38. package/dist/logging/StartupLogger.js.map +1 -0
  39. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
  40. package/dist/logging/variablesLoggingMiddleware.js +21 -2
  41. package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
  42. package/dist/resolvers/AgentSessionResolver.d.ts +42 -0
  43. package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -0
  44. package/dist/resolvers/AgentSessionResolver.js +152 -0
  45. package/dist/resolvers/AgentSessionResolver.js.map +1 -0
  46. package/dist/resolvers/EntityPermissionResolver.d.ts +16 -0
  47. package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -0
  48. package/dist/resolvers/EntityPermissionResolver.js +95 -0
  49. package/dist/resolvers/EntityPermissionResolver.js.map +1 -0
  50. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +688 -0
  51. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -0
  52. package/dist/resolvers/RealtimeClientSessionResolver.js +1774 -0
  53. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -0
  54. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +359 -0
  55. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -0
  56. package/dist/resolvers/RemoteBrowserActionResolver.js +896 -0
  57. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -0
  58. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  59. package/dist/services/ScheduledJobsService.js +6 -5
  60. package/dist/services/ScheduledJobsService.js.map +1 -1
  61. package/package.json +78 -74
  62. package/src/__tests__/RealtimeClientSessionResolver.test.ts +2605 -0
  63. package/src/__tests__/RemoteBrowserAudioStream.test.ts +174 -0
  64. package/src/__tests__/SessionJanitor.test.ts +234 -0
  65. package/src/__tests__/SessionManager.test.ts +465 -0
  66. package/src/__tests__/subscriptionRedaction.test.ts +5 -0
  67. package/src/agentSessions/HostInstance.ts +53 -0
  68. package/src/agentSessions/SessionJanitor.ts +267 -0
  69. package/src/agentSessions/SessionManager.ts +446 -0
  70. package/src/agentSessions/index.ts +27 -0
  71. package/src/auth/initializeProviders.ts +6 -1
  72. package/src/context.ts +42 -7
  73. package/src/generated/generated.ts +3111 -567
  74. package/src/generic/ResolverBase.ts +1 -1
  75. package/src/generic/RunViewResolver.ts +9 -9
  76. package/src/index.ts +98 -41
  77. package/src/logging/StartupLogger.ts +317 -0
  78. package/src/logging/variablesLoggingMiddleware.ts +25 -5
  79. package/src/resolvers/AgentSessionResolver.ts +138 -0
  80. package/src/resolvers/EntityPermissionResolver.ts +73 -0
  81. package/src/resolvers/RealtimeClientSessionResolver.ts +2162 -0
  82. package/src/resolvers/RemoteBrowserActionResolver.ts +907 -0
  83. package/src/services/ScheduledJobsService.ts +6 -5
@@ -0,0 +1,2605 @@
1
+ // type-graphql decorators (`@ObjectType`, `@Field`, `@Mutation`) on the resolver call
2
+ // `Reflect.getMetadata`, which only exists when this polyfill is loaded first. Vitest does not
3
+ // bring it in automatically — this line MUST precede any import that pulls in the resolver file.
4
+ import 'reflect-metadata';
5
+
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
7
+
8
+ // --- Mock CanRun authorization (AIAgentPermissionHelper.HasPermission) plus the
9
+ // provider-scoped AIEngineBase whose cached metadata serves the resolver's pairing-row
10
+ // and channel-definition reads (the engine replaced the per-call RunViews) ---
11
+ const hasPermissionMock = vi.fn<[], Promise<boolean>>();
12
+ /** Cached `MJ: AI Agent Co Agents` rows the fake engine serves. */
13
+ interface FakeCoAgentRow {
14
+ ID: string;
15
+ CoAgentID: string;
16
+ TargetAgentID: string | null;
17
+ TargetAgentTypeID?: string | null;
18
+ Type: 'CoAgent' | 'Delegate' | 'Fallback' | 'Observer' | 'Peer' | 'Reviewer';
19
+ Status: 'Active' | 'Disabled';
20
+ IsDefault: boolean;
21
+ Sequence: number;
22
+ }
23
+ /** Cached `MJ: AI Agent Channels` rows the fake engine serves. */
24
+ interface FakeChannelRow {
25
+ ID: string;
26
+ Name: string;
27
+ IsActive: boolean;
28
+ }
29
+ const engineCoAgentsMock = vi.fn((): FakeCoAgentRow[] => []);
30
+ const engineChannelsMock = vi.fn((): FakeChannelRow[] => []);
31
+ const engineConfigMock = vi.fn(async (): Promise<void> => undefined);
32
+ vi.mock('@memberjunction/ai-engine-base', () => ({
33
+ AIAgentPermissionHelper: {
34
+ HasPermission: (...args: unknown[]) => hasPermissionMock(...(args as [])),
35
+ },
36
+ AIEngineBase: {
37
+ GetProviderInstance: vi.fn(() => ({
38
+ Config: engineConfigMock,
39
+ get AgentCoAgents() {
40
+ return engineCoAgentsMock();
41
+ },
42
+ get AgentChannels() {
43
+ return engineChannelsMock();
44
+ },
45
+ })),
46
+ },
47
+ }));
48
+
49
+ // --- Mock AIEngine so the Realtime Co-Agent resolves without DB-backed config ---
50
+ /** Minimal cached-agent shape the resolver's co-agent chain reads. */
51
+ interface FakeAgent {
52
+ ID: string;
53
+ Name: string;
54
+ Status?: string;
55
+ TypeID?: string | null;
56
+ DefaultCoAgentID?: string | null;
57
+ }
58
+ interface FakeAgentType {
59
+ ID: string;
60
+ Name: string;
61
+ DefaultCoAgentID?: string | null;
62
+ }
63
+ const agentsMock = vi.fn((): FakeAgent[] => [{ ID: 'co-agent-1', Name: 'Realtime Co-Agent' }]);
64
+ const agentTypesMock = vi.fn((): FakeAgentType[] => []);
65
+ vi.mock('@memberjunction/aiengine', () => ({
66
+ AIEngine: {
67
+ Instance: {
68
+ Config: vi.fn(async () => undefined),
69
+ get Agents() {
70
+ return agentsMock();
71
+ },
72
+ get AgentTypes() {
73
+ return agentTypesMock();
74
+ },
75
+ },
76
+ },
77
+ }));
78
+
79
+ // --- Mock the client-direct service (prepare + relayed-tool execution + cancel registry) plus
80
+ // the channel-plugin host (SaveSessionChannelState's pre-persistence normalization hook) ---
81
+ const prepareClientSessionMock = vi.fn();
82
+ const executeRelayedToolMock = vi.fn();
83
+ const cancelInFlightMock = vi.fn((_agentSessionID: string, _callID?: string): number => 0);
84
+ const onChannelStateSaveMock = vi.fn(
85
+ async (_agentSessionID: string, _channelName: string, stateJson: string): Promise<string> => stateJson,
86
+ );
87
+ // PARTIAL mock: the service + channel host are stubbed, but the PURE co-agent config module
88
+ // (DeepMergeConfigs / ResolveEffectiveRealtimeConfig / EvaluateRuntimeOverrideAuthorization /
89
+ // ParseRealtimeTypeConfiguration) stays REAL — the resolver's pairing/override gate is under test.
90
+ vi.mock('@memberjunction/ai-agents', async (importOriginal) => {
91
+ const actual = await importOriginal<Record<string, unknown>>();
92
+ // The prompt-run persistence methods only use the test-supplied provider + LogError, so forward them to a
93
+ // REAL service instance — the resolver's transcript/usage tests assert their actual load/modify/save effect.
94
+ const RealService = actual.RealtimeClientSessionService as new () => {
95
+ AppendPromptRunMessage: (...a: unknown[]) => Promise<boolean>;
96
+ AccumulatePromptRunUsage: (...a: unknown[]) => Promise<boolean>;
97
+ };
98
+ const realService = new RealService();
99
+ return {
100
+ ...actual,
101
+ RealtimeClientSessionService: class {
102
+ PrepareClientSession = prepareClientSessionMock;
103
+ ExecuteRelayedTool = executeRelayedToolMock;
104
+ CancelInFlightDelegations = cancelInFlightMock;
105
+ AppendPromptRunMessage = (...a: unknown[]) => realService.AppendPromptRunMessage(...a);
106
+ AccumulatePromptRunUsage = (...a: unknown[]) => realService.AccumulatePromptRunUsage(...a);
107
+ },
108
+ RealtimeChannelServerHost: {
109
+ get Instance() {
110
+ return { OnChannelStateSave: onChannelStateSaveMock };
111
+ },
112
+ },
113
+ };
114
+ });
115
+
116
+ // --- Mock SessionManager (create / close / heartbeat) ---
117
+ const createSessionMock = vi.fn();
118
+ const closeSessionMock = vi.fn(async () => true);
119
+ const heartbeatMock = vi.fn(async () => true);
120
+ vi.mock('../agentSessions/index.js', () => ({
121
+ SessionManager: class {
122
+ CreateSession = createSessionMock;
123
+ CloseSession = closeSessionMock;
124
+ Heartbeat = heartbeatMock;
125
+ },
126
+ }));
127
+
128
+ // --- Mock provider helper so the resolver gets a controllable provider ---
129
+ let currentProvider: unknown;
130
+ vi.mock('../util.js', () => ({
131
+ GetReadWriteProvider: () => currentProvider,
132
+ }));
133
+
134
+ import { RealtimeClientSessionResolver } from '../resolvers/RealtimeClientSessionResolver.js';
135
+ import type { AppContext } from '../types.js';
136
+
137
+ const USER = { ID: 'user-1', Email: 'tester@example.com' };
138
+
139
+ /**
140
+ * A pre-granted 'Realtime: Advanced Session Controls' authorization row, shaped like the
141
+ * AuthorizationInfo surface the resolver's gate consumes (Name lookup + UserCanExecute +
142
+ * ParentID hierarchy walk). Attach to a test provider's `Authorizations` to authorize overrides.
143
+ */
144
+ const GRANTED_ADVANCED_CONTROLS_AUTH = {
145
+ ID: 'auth-advanced-1',
146
+ Name: 'Realtime: Advanced Session Controls',
147
+ ParentID: null,
148
+ UserCanExecute: () => true,
149
+ };
150
+
151
+ /** The same authorization row, but the caller's roles do NOT grant it. */
152
+ const DENIED_ADVANCED_CONTROLS_AUTH = {
153
+ ID: 'auth-advanced-1',
154
+ Name: 'Realtime: Advanced Session Controls',
155
+ ParentID: null,
156
+ UserCanExecute: () => false,
157
+ };
158
+
159
+ /** Build a resolver with GetUserFromPayload stubbed to return our test user. */
160
+ function makeResolver(): RealtimeClientSessionResolver {
161
+ const resolver = new RealtimeClientSessionResolver();
162
+ // GetUserFromPayload lives on ResolverBase; stub it to bypass the real auth machinery.
163
+ (resolver as unknown as { GetUserFromPayload: () => unknown }).GetUserFromPayload = () => USER;
164
+ return resolver;
165
+ }
166
+
167
+ /** Minimal AppContext — providers is consumed by the mocked GetReadWriteProvider, so contents are inert. */
168
+ function makeCtx(): AppContext {
169
+ return { userPayload: { sessionId: 'pubsub-session-1' }, providers: [] } as unknown as AppContext;
170
+ }
171
+
172
+ /** A controllable PubSubEngine stub whose `publish` calls are asserted. */
173
+ function makePubSub(): { publish: ReturnType<typeof vi.fn> } {
174
+ return { publish: vi.fn(async () => undefined) };
175
+ }
176
+
177
+ interface FakeSession {
178
+ [key: string]: unknown;
179
+ NewRecord: () => void;
180
+ Save: () => Promise<boolean>;
181
+ Load: (id: string) => Promise<boolean>;
182
+ LatestResult?: { CompleteMessage?: string };
183
+ }
184
+
185
+ function makeSessionEntity(overrides: Partial<FakeSession> = {}): FakeSession {
186
+ return {
187
+ ID: 'session-1',
188
+ Status: 'Active',
189
+ ConversationID: 'conv-1',
190
+ UserID: 'user-1',
191
+ Config_: JSON.stringify({ targetAgentID: 'target-1' }),
192
+ NewRecord: vi.fn(),
193
+ Save: vi.fn(async () => true),
194
+ Load: vi.fn(async () => true),
195
+ LatestResult: { CompleteMessage: '' },
196
+ ...overrides,
197
+ };
198
+ }
199
+
200
+ /** Provider whose GetEntityObject returns a fixed entity per entity name. */
201
+ function makeProvider(factory: (entityName: string) => FakeSession): unknown {
202
+ return { GetEntityObject: vi.fn(async (name: string) => factory(name)) };
203
+ }
204
+
205
+ beforeEach(() => {
206
+ hasPermissionMock.mockReset();
207
+ engineCoAgentsMock.mockReset();
208
+ engineCoAgentsMock.mockReturnValue([]);
209
+ engineChannelsMock.mockReset();
210
+ engineChannelsMock.mockReturnValue([]);
211
+ engineConfigMock.mockReset();
212
+ engineConfigMock.mockResolvedValue(undefined);
213
+ prepareClientSessionMock.mockReset();
214
+ executeRelayedToolMock.mockReset();
215
+ cancelInFlightMock.mockReset();
216
+ cancelInFlightMock.mockReturnValue(0);
217
+ onChannelStateSaveMock.mockReset();
218
+ onChannelStateSaveMock.mockImplementation(
219
+ async (_agentSessionID: string, _channelName: string, stateJson: string) => stateJson,
220
+ );
221
+ createSessionMock.mockReset();
222
+ closeSessionMock.mockClear();
223
+ heartbeatMock.mockClear();
224
+ agentsMock.mockReturnValue([{ ID: 'co-agent-1', Name: 'Realtime Co-Agent' }]);
225
+ agentTypesMock.mockReturnValue([]);
226
+ });
227
+
228
+ describe('RealtimeClientSessionResolver.StartRealtimeClientSession', () => {
229
+ it('denies and creates no session when CanRun on the target is false', async () => {
230
+ hasPermissionMock.mockResolvedValue(false);
231
+ currentProvider = makeProvider(() => makeSessionEntity());
232
+ const resolver = makeResolver();
233
+
234
+ await expect(
235
+ resolver.StartRealtimeClientSession('target-1', makeCtx()),
236
+ ).rejects.toThrow(/not authorized/i);
237
+
238
+ expect(createSessionMock).not.toHaveBeenCalled();
239
+ expect(prepareClientSessionMock).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it('returns the ephemeral config and stores targetAgentID + run ids in session config on success', async () => {
243
+ hasPermissionMock.mockResolvedValue(true);
244
+ currentProvider = makeProvider(() => makeSessionEntity());
245
+ const createdSession = makeSessionEntity({ ID: 'session-9', ConversationID: 'conv-9' });
246
+ createSessionMock.mockResolvedValue(createdSession);
247
+ prepareClientSessionMock.mockResolvedValue({
248
+ Success: true,
249
+ CoAgentRunID: 'co-run-9',
250
+ PromptRunID: 'prompt-run-9',
251
+ CoAgentRunStepID: 'run-step-9',
252
+ ClientConfig: {
253
+ Provider: 'openai',
254
+ Model: 'gpt-realtime',
255
+ EphemeralToken: 'ek_abc',
256
+ ExpiresAt: '2026-01-01T00:00:00Z',
257
+ SessionConfig: { instructions: 'hi' },
258
+ },
259
+ });
260
+ const resolver = makeResolver();
261
+
262
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), 'conv-9');
263
+
264
+ // CanRun was checked on the TARGET agent.
265
+ expect(hasPermissionMock).toHaveBeenCalledWith('target-1', USER, 'run');
266
+ // Session created under the co-agent, with targetAgentID persisted authoritatively.
267
+ const createArg = createSessionMock.mock.calls[0][0] as { agentID: string; config: string };
268
+ expect(createArg.agentID).toBe('co-agent-1');
269
+ expect(JSON.parse(createArg.config)).toEqual({ targetAgentID: 'target-1' });
270
+ // After prepare, the observability run + run-step ids are written back into the session config + saved.
271
+ expect(JSON.parse(createdSession.Config_ as string)).toEqual({
272
+ targetAgentID: 'target-1', coAgentRunID: 'co-run-9', promptRunID: 'prompt-run-9', coAgentRunStepID: 'run-step-9',
273
+ });
274
+ expect(createdSession.Save).toHaveBeenCalled();
275
+ // Ephemeral config surfaced to the browser.
276
+ expect(result.AgentSessionId).toBe('session-9');
277
+ expect(result.ConversationId).toBe('conv-9');
278
+ expect(result.Provider).toBe('openai');
279
+ expect(result.Model).toBe('gpt-realtime');
280
+ expect(result.EphemeralToken).toBe('ek_abc');
281
+ expect(result.ExpiresAt).toBe('2026-01-01T00:00:00Z');
282
+ expect(JSON.parse(result.SessionConfigJson)).toEqual({ instructions: 'hi' });
283
+ });
284
+
285
+ it('threads preferredModelId to the prepare service and surfaces ModelName + narration template', async () => {
286
+ hasPermissionMock.mockResolvedValue(true);
287
+ // Explicit model selection is gated by 'Realtime: Advanced Session Controls' — grant it.
288
+ currentProvider = makeProvider(() => makeSessionEntity());
289
+ (currentProvider as { Authorizations?: unknown }).Authorizations = [GRANTED_ADVANCED_CONTROLS_AUTH];
290
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-10' }));
291
+ prepareClientSessionMock.mockResolvedValue({
292
+ Success: true,
293
+ ModelID: 'model-77',
294
+ ModelName: 'GPT Realtime 2',
295
+ NarrationInstructionsTemplate: 'Progress: "{{ progressMessage }}" — narrate it.',
296
+ ClientConfig: {
297
+ Provider: 'openai',
298
+ Model: 'gpt-realtime-2',
299
+ EphemeralToken: 'ek_xyz',
300
+ ExpiresAt: '2026-01-01T00:00:00Z',
301
+ SessionConfig: {},
302
+ },
303
+ });
304
+ const resolver = makeResolver();
305
+
306
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), 'conv-1', undefined, 'model-77');
307
+
308
+ // The explicit model choice is threaded into the prepare input.
309
+ const prepArg = prepareClientSessionMock.mock.calls[0][0] as { PreferredModelID?: string };
310
+ expect(prepArg.PreferredModelID).toBe('model-77');
311
+ // The active model name + narration template surface on the result.
312
+ expect(result.ModelName).toBe('GPT Realtime 2');
313
+ expect(result.NarrationInstructionsTemplate).toContain('{{ progressMessage }}');
314
+ });
315
+
316
+ it('omits preferredModelId from the prepare input when not supplied (nullable result fields)', async () => {
317
+ hasPermissionMock.mockResolvedValue(true);
318
+ currentProvider = makeProvider(() => makeSessionEntity());
319
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-11' }));
320
+ prepareClientSessionMock.mockResolvedValue({
321
+ Success: true,
322
+ ClientConfig: {
323
+ Provider: 'openai',
324
+ Model: 'gpt-realtime',
325
+ EphemeralToken: 'ek_abc',
326
+ ExpiresAt: '2026-01-01T00:00:00Z',
327
+ SessionConfig: {},
328
+ },
329
+ });
330
+ const resolver = makeResolver();
331
+
332
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx());
333
+
334
+ const prepArg = prepareClientSessionMock.mock.calls[0][0] as { PreferredModelID?: string };
335
+ expect(prepArg.PreferredModelID).toBeUndefined();
336
+ expect(result.ModelName).toBeUndefined();
337
+ expect(result.NarrationInstructionsTemplate).toBeUndefined();
338
+ });
339
+
340
+ it('propagates the preferred-model failure (and closes the session) — no silent fallback', async () => {
341
+ hasPermissionMock.mockResolvedValue(true);
342
+ currentProvider = makeProvider(() => makeSessionEntity());
343
+ (currentProvider as { Authorizations?: unknown }).Authorizations = [GRANTED_ADVANCED_CONTROLS_AUTH];
344
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-12' }));
345
+ prepareClientSessionMock.mockResolvedValue({
346
+ Success: false,
347
+ ErrorMessage: "The requested model 'Old Realtime' is not active and cannot be used for a voice session.",
348
+ });
349
+ const resolver = makeResolver();
350
+
351
+ await expect(
352
+ resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, undefined, 'model-old'),
353
+ ).rejects.toThrow(/not active/);
354
+
355
+ expect(closeSessionMock).toHaveBeenCalledWith('session-12', USER, currentProvider, 'Error');
356
+ });
357
+
358
+ it('closes the session and throws when prepare fails (no half-open session)', async () => {
359
+ hasPermissionMock.mockResolvedValue(true);
360
+ currentProvider = makeProvider(() => makeSessionEntity());
361
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-bad' }));
362
+ prepareClientSessionMock.mockResolvedValue({ Success: false, ErrorMessage: 'no model' });
363
+ const resolver = makeResolver();
364
+
365
+ await expect(
366
+ resolver.StartRealtimeClientSession('target-1', makeCtx()),
367
+ ).rejects.toThrow(/no model/);
368
+
369
+ expect(closeSessionMock).toHaveBeenCalledWith('session-bad', USER, currentProvider, 'Error');
370
+ });
371
+ });
372
+
373
+ describe('RealtimeClientSessionResolver — authentication gate', () => {
374
+ /** Resolver whose GetUserFromPayload yields NO user (unauthenticated request). */
375
+ function makeAnonymousResolver(): RealtimeClientSessionResolver {
376
+ const resolver = new RealtimeClientSessionResolver();
377
+ (resolver as unknown as { GetUserFromPayload: () => unknown }).GetUserFromPayload = () => undefined;
378
+ return resolver;
379
+ }
380
+
381
+ it('StartRealtimeClientSession throws before touching authorization or session creation', async () => {
382
+ currentProvider = makeProvider(() => makeSessionEntity());
383
+ const resolver = makeAnonymousResolver();
384
+
385
+ await expect(
386
+ resolver.StartRealtimeClientSession('target-1', makeCtx()),
387
+ ).rejects.toThrow(/not authenticated/i);
388
+
389
+ expect(hasPermissionMock).not.toHaveBeenCalled();
390
+ expect(createSessionMock).not.toHaveBeenCalled();
391
+ });
392
+
393
+ it('ExecuteRealtimeSessionTool throws before any session load or tool execution', async () => {
394
+ const provider = makeProvider(() => makeSessionEntity());
395
+ currentProvider = provider;
396
+ const resolver = makeAnonymousResolver();
397
+
398
+ await expect(
399
+ resolver.ExecuteRealtimeSessionTool('session-1', 'c1', 'x', '{}', makeCtx(), makePubSub()),
400
+ ).rejects.toThrow(/not authenticated/i);
401
+
402
+ expect((provider as { GetEntityObject: ReturnType<typeof vi.fn> }).GetEntityObject).not.toHaveBeenCalled();
403
+ expect(executeRelayedToolMock).not.toHaveBeenCalled();
404
+ });
405
+ });
406
+
407
+ describe('RealtimeClientSessionResolver.StartRealtimeClientSession — co-agent resolution chain', () => {
408
+ const REALTIME_TYPE: FakeAgentType = { ID: 'type-realtime', Name: 'Realtime' };
409
+ const LOOP_TYPE: FakeAgentType = { ID: 'type-loop', Name: 'Loop' };
410
+ const GLOBAL_CO: FakeAgent = { ID: 'co-global', Name: 'Realtime Co-Agent', Status: 'Active', TypeID: 'type-realtime' };
411
+ /** The pre-rename legacy seed name — still resolvable as a deprecated fallback. */
412
+ const LEGACY_GLOBAL_CO: FakeAgent = { ID: 'co-global-legacy', Name: 'Voice Co-Agent', Status: 'Active', TypeID: 'type-realtime' };
413
+ /** A valid alternative co-agent (Active + Realtime-type) usable at any chain step. */
414
+ const PERSONA_CO: FakeAgent = { ID: 'co-persona', Name: 'Sales Persona Voice', Status: 'Active', TypeID: 'type-realtime' };
415
+ const TYPE_CO: FakeAgent = { ID: 'co-type-default', Name: 'Loop Default Voice', Status: 'Active', TypeID: 'type-realtime' };
416
+
417
+ function setupHappyStart(): void {
418
+ hasPermissionMock.mockResolvedValue(true);
419
+ currentProvider = makeProvider(() => makeSessionEntity());
420
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-co' }));
421
+ prepareClientSessionMock.mockResolvedValue({
422
+ Success: true,
423
+ ClientConfig: {
424
+ Provider: 'openai',
425
+ Model: 'gpt-realtime',
426
+ EphemeralToken: 'ek_abc',
427
+ ExpiresAt: '2026-01-01T00:00:00Z',
428
+ SessionConfig: {},
429
+ },
430
+ });
431
+ }
432
+
433
+ /** Starts a session and returns the co-agent id the durable session was created under. */
434
+ async function startAndGetCoAgentID(targetAgentId: string, coAgentId?: string): Promise<string> {
435
+ const resolver = makeResolver();
436
+ await resolver.StartRealtimeClientSession(targetAgentId, makeCtx(), undefined, undefined, undefined, undefined, coAgentId);
437
+ const createArg = createSessionMock.mock.calls[0][0] as { agentID: string };
438
+ return createArg.agentID;
439
+ }
440
+
441
+ it('runtime coAgentId wins over agent-level AND type-level defaults (step 1 beats 2/3/4)', async () => {
442
+ setupHappyStart();
443
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, { ...LOOP_TYPE, DefaultCoAgentID: 'co-type-default' }]);
444
+ agentsMock.mockReturnValue([
445
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: 'co-persona' },
446
+ PERSONA_CO, TYPE_CO, GLOBAL_CO,
447
+ { ID: 'co-explicit', Name: 'Explicit Voice', Status: 'Active', TypeID: 'type-realtime' },
448
+ ]);
449
+
450
+ const coAgentID = await startAndGetCoAgentID('target-1', 'co-explicit');
451
+ expect(coAgentID).toBe('co-explicit');
452
+ });
453
+
454
+ it('fails LOUD (throws, no session) when the explicit coAgentId does not exist', async () => {
455
+ setupHappyStart();
456
+ agentTypesMock.mockReturnValue([REALTIME_TYPE]);
457
+ agentsMock.mockReturnValue([GLOBAL_CO]);
458
+ const resolver = makeResolver();
459
+
460
+ await expect(
461
+ resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, undefined, undefined, undefined, 'co-missing'),
462
+ ).rejects.toThrow(/Invalid coAgentId 'co-missing'/);
463
+ expect(createSessionMock).not.toHaveBeenCalled();
464
+ });
465
+
466
+ it('fails LOUD when the explicit coAgentId is not Active', async () => {
467
+ setupHappyStart();
468
+ agentTypesMock.mockReturnValue([REALTIME_TYPE]);
469
+ agentsMock.mockReturnValue([
470
+ GLOBAL_CO,
471
+ { ID: 'co-disabled', Name: 'Disabled Voice', Status: 'Disabled', TypeID: 'type-realtime' },
472
+ ]);
473
+ const resolver = makeResolver();
474
+
475
+ await expect(
476
+ resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, undefined, undefined, undefined, 'co-disabled'),
477
+ ).rejects.toThrow(/not Active/);
478
+ expect(createSessionMock).not.toHaveBeenCalled();
479
+ });
480
+
481
+ it('fails LOUD when the explicit coAgentId is not of the Realtime agent type', async () => {
482
+ setupHappyStart();
483
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, LOOP_TYPE]);
484
+ agentsMock.mockReturnValue([
485
+ GLOBAL_CO,
486
+ { ID: 'co-loop', Name: 'Loop Agent', Status: 'Active', TypeID: 'type-loop' },
487
+ ]);
488
+ const resolver = makeResolver();
489
+
490
+ await expect(
491
+ resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, undefined, undefined, undefined, 'co-loop'),
492
+ ).rejects.toThrow(/not of the 'Realtime' agent type/);
493
+ expect(createSessionMock).not.toHaveBeenCalled();
494
+ });
495
+
496
+ it("uses the target agent's DefaultCoAgentID (step 2 beats 3/4) when no runtime param is given", async () => {
497
+ setupHappyStart();
498
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, { ...LOOP_TYPE, DefaultCoAgentID: 'co-type-default' }]);
499
+ agentsMock.mockReturnValue([
500
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: 'co-persona' },
501
+ PERSONA_CO, TYPE_CO, GLOBAL_CO,
502
+ ]);
503
+
504
+ const coAgentID = await startAndGetCoAgentID('target-1');
505
+ expect(coAgentID).toBe('co-persona');
506
+ });
507
+
508
+ it("uses the agent TYPE's DefaultCoAgentID (step 3 beats 4) when the agent has none", async () => {
509
+ setupHappyStart();
510
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, LOOP_TYPE]);
511
+ engineCoAgentsMock.mockReturnValue([
512
+ { ID: 'pair-typedef', CoAgentID: 'co-type-default', TargetAgentID: null, TargetAgentTypeID: 'type-loop', Type: 'CoAgent' as const, Status: 'Active' as const, IsDefault: true, Sequence: 0 },
513
+ ]);
514
+ agentsMock.mockReturnValue([
515
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: null },
516
+ TYPE_CO, GLOBAL_CO,
517
+ ]);
518
+
519
+ const coAgentID = await startAndGetCoAgentID('target-1');
520
+ expect(coAgentID).toBe('co-type-default');
521
+ });
522
+
523
+ it('falls through (warn, no throw) past an INVALID agent-level default to the type default', async () => {
524
+ setupHappyStart();
525
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, LOOP_TYPE]);
526
+ engineCoAgentsMock.mockReturnValue([
527
+ { ID: 'pair-typedef', CoAgentID: 'co-type-default', TargetAgentID: null, TargetAgentTypeID: 'type-loop', Type: 'CoAgent' as const, Status: 'Active' as const, IsDefault: true, Sequence: 0 },
528
+ ]);
529
+ agentsMock.mockReturnValue([
530
+ // Agent-level default points at a Disabled co-agent — tolerated, falls through.
531
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: 'co-disabled' },
532
+ { ID: 'co-disabled', Name: 'Disabled Voice', Status: 'Disabled', TypeID: 'type-realtime' },
533
+ TYPE_CO, GLOBAL_CO,
534
+ ]);
535
+
536
+ const coAgentID = await startAndGetCoAgentID('target-1');
537
+ expect(coAgentID).toBe('co-type-default');
538
+ });
539
+
540
+ it('falls through past an INVALID type-level default (dangling reference) to the global Realtime Co-Agent', async () => {
541
+ setupHappyStart();
542
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, LOOP_TYPE]);
543
+ engineCoAgentsMock.mockReturnValue([
544
+ { ID: 'pair-typedef', CoAgentID: 'co-deleted', TargetAgentID: null, TargetAgentTypeID: 'type-loop', Type: 'CoAgent' as const, Status: 'Active' as const, IsDefault: true, Sequence: 0 },
545
+ ]);
546
+ agentsMock.mockReturnValue([
547
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: null },
548
+ GLOBAL_CO,
549
+ ]);
550
+
551
+ const coAgentID = await startAndGetCoAgentID('target-1');
552
+ expect(coAgentID).toBe('co-global');
553
+ });
554
+
555
+ it('falls through past a mis-typed (non-Realtime) agent-level default all the way to global', async () => {
556
+ setupHappyStart();
557
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, LOOP_TYPE]);
558
+ agentsMock.mockReturnValue([
559
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: 'co-loop' },
560
+ { ID: 'co-loop', Name: 'Loop Agent', Status: 'Active', TypeID: 'type-loop' },
561
+ GLOBAL_CO,
562
+ ]);
563
+
564
+ const coAgentID = await startAndGetCoAgentID('target-1');
565
+ expect(coAgentID).toBe('co-global');
566
+ });
567
+
568
+ it('resolves the global Realtime Co-Agent by name when no defaults are configured anywhere (step 4)', async () => {
569
+ setupHappyStart();
570
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, LOOP_TYPE]);
571
+ agentsMock.mockReturnValue([
572
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: null },
573
+ GLOBAL_CO,
574
+ ]);
575
+
576
+ const coAgentID = await startAndGetCoAgentID('target-1');
577
+ expect(coAgentID).toBe('co-global');
578
+ });
579
+
580
+ it('falls back to the DEPRECATED legacy seed name when no Realtime Co-Agent exists (un-resynced deployment)', async () => {
581
+ setupHappyStart();
582
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, LOOP_TYPE]);
583
+ agentsMock.mockReturnValue([
584
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: null },
585
+ LEGACY_GLOBAL_CO,
586
+ ]);
587
+
588
+ const coAgentID = await startAndGetCoAgentID('target-1');
589
+ expect(coAgentID).toBe('co-global-legacy');
590
+ });
591
+
592
+ it('prefers the current Realtime Co-Agent name over the legacy name when BOTH exist', async () => {
593
+ setupHappyStart();
594
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, LOOP_TYPE]);
595
+ agentsMock.mockReturnValue([
596
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: null },
597
+ LEGACY_GLOBAL_CO,
598
+ GLOBAL_CO,
599
+ ]);
600
+
601
+ const coAgentID = await startAndGetCoAgentID('target-1');
602
+ expect(coAgentID).toBe('co-global');
603
+ });
604
+
605
+ it('throws (naming the CURRENT seed name) when the chain exhausts and neither global co-agent name exists', async () => {
606
+ setupHappyStart();
607
+ agentTypesMock.mockReturnValue([REALTIME_TYPE, LOOP_TYPE]);
608
+ agentsMock.mockReturnValue([
609
+ { ID: 'target-1', Name: 'Target', Status: 'Active', TypeID: 'type-loop', DefaultCoAgentID: null },
610
+ ]);
611
+ const resolver = makeResolver();
612
+
613
+ await expect(
614
+ resolver.StartRealtimeClientSession('target-1', makeCtx()),
615
+ ).rejects.toThrow(/'Realtime Co-Agent' agent is not configured/);
616
+ expect(createSessionMock).not.toHaveBeenCalled();
617
+ });
618
+ });
619
+
620
+ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
621
+ it('enforces ownership — rejects when the caller does not own the session', async () => {
622
+ currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'someone-else' }));
623
+ const resolver = makeResolver();
624
+
625
+ await expect(
626
+ resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub()),
627
+ ).rejects.toThrow(/do not own/i);
628
+
629
+ expect(executeRelayedToolMock).not.toHaveBeenCalled();
630
+ });
631
+
632
+ it('reads the target + co-agent run id from the session config and returns ResultJson', async () => {
633
+ currentProvider = makeProvider(() =>
634
+ makeSessionEntity({
635
+ Config_: JSON.stringify({ targetAgentID: 'target-from-session', coAgentRunID: 'co-run-77' }),
636
+ }),
637
+ );
638
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
639
+ const resolver = makeResolver();
640
+
641
+ const out = await resolver.ExecuteRealtimeSessionTool(
642
+ 'session-1',
643
+ 'call-1',
644
+ 'invoke-target-agent',
645
+ '{"request":"do it"}',
646
+ makeCtx(),
647
+ makePubSub(),
648
+ );
649
+
650
+ expect(out).toBe('{"ok":true}');
651
+ const relayArg = executeRelayedToolMock.mock.calls[0][0] as {
652
+ TargetAgentID: string;
653
+ ParentRunID?: string;
654
+ Call: { CallID: string; ToolName: string; Arguments: string };
655
+ };
656
+ // Target comes from the session, NOT the client.
657
+ expect(relayArg.TargetAgentID).toBe('target-from-session');
658
+ // Delegated run nests under the co-agent observability run from the session config.
659
+ expect(relayArg.ParentRunID).toBe('co-run-77');
660
+ expect(relayArg.Call).toEqual({ CallID: 'call-1', ToolName: 'invoke-target-agent', Arguments: '{"request":"do it"}' });
661
+ expect(heartbeatMock).toHaveBeenCalledWith('session-1', USER, currentProvider);
662
+ });
663
+
664
+ it('rejects a closed session', async () => {
665
+ currentProvider = makeProvider(() => makeSessionEntity({ Status: 'Closed' }));
666
+ const resolver = makeResolver();
667
+
668
+ await expect(
669
+ resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'x', '{}', makeCtx(), makePubSub()),
670
+ ).rejects.toThrow(/closed/i);
671
+ });
672
+
673
+ it('publishes significant delegation progress (and drops noise) tagged for this voice session', async () => {
674
+ currentProvider = makeProvider(() => makeSessionEntity());
675
+ // Invoke the OnProgress callback the resolver builds, capturing what it publishes.
676
+ executeRelayedToolMock.mockImplementation(async (input: { OnProgress: (p: unknown) => void }) => {
677
+ input.OnProgress({ step: 'initialization', message: 'noise' }); // dropped
678
+ input.OnProgress({ step: 'prompt_execution', message: 'thinking', percentage: 42 }); // published
679
+ return { ResultJson: '{"ok":true}', Success: true };
680
+ });
681
+ const pubSub = makePubSub();
682
+ const resolver = makeResolver();
683
+
684
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-7', 'invoke-target-agent', '{}', makeCtx(), pubSub);
685
+
686
+ // Only the significant step published — exactly one publish.
687
+ expect(pubSub.publish).toHaveBeenCalledTimes(1);
688
+ const [topic, payload] = pubSub.publish.mock.calls[0] as [string, { message: string; sessionId: string }];
689
+ expect(topic).toBe('PUSH_STATUS_UPDATES'); // PUSH_STATUS_UPDATES_TOPIC value
690
+ expect(payload.sessionId).toBe('pubsub-session-1');
691
+ expect(JSON.parse(payload.message)).toMatchObject({
692
+ resolver: 'RealtimeClientSessionResolver',
693
+ type: 'RealtimeDelegationProgress',
694
+ agentSessionID: 'session-1',
695
+ callID: 'call-7',
696
+ step: 'prompt_execution',
697
+ message: 'thinking',
698
+ percentage: 42,
699
+ });
700
+ });
701
+
702
+ it('persists pendingFeedbackRunID when a relayed call leaves a run paused (preserving the run/step ids)', async () => {
703
+ const session = makeSessionEntity({
704
+ Config_: JSON.stringify({ targetAgentID: 'target-1', coAgentRunID: 'co-run-1', coAgentRunStepID: 'run-step-1' }),
705
+ });
706
+ currentProvider = makeProvider(() => session);
707
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"q":1}', Success: true, PausedRunID: 'paused-1' });
708
+ const resolver = makeResolver();
709
+
710
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
711
+
712
+ expect(JSON.parse(session.Config_ as string)).toMatchObject({
713
+ targetAgentID: 'target-1',
714
+ coAgentRunID: 'co-run-1',
715
+ coAgentRunStepID: 'run-step-1',
716
+ pendingFeedbackRunID: 'paused-1',
717
+ });
718
+ expect(session.Save).toHaveBeenCalled();
719
+ });
720
+
721
+ it('consumes pendingFeedbackRunID as ResumeRunID and clears it on the next call', async () => {
722
+ const session = makeSessionEntity({
723
+ Config_: JSON.stringify({ targetAgentID: 'target-1', pendingFeedbackRunID: 'paused-1' }),
724
+ });
725
+ currentProvider = makeProvider(() => session);
726
+ // Resumed run completes (no new pause).
727
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"done":1}', Success: true });
728
+ const resolver = makeResolver();
729
+
730
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-2', 'invoke-target-agent', '{"request":"yes"}', makeCtx(), makePubSub());
731
+
732
+ // The paused id was passed in to resume the run...
733
+ const relayArg = executeRelayedToolMock.mock.calls[0][0] as { ResumeRunID?: string };
734
+ expect(relayArg.ResumeRunID).toBe('paused-1');
735
+ // ...and cleared from the session config afterward.
736
+ expect(JSON.parse(session.Config_ as string).pendingFeedbackRunID).toBeUndefined();
737
+ expect(session.Save).toHaveBeenCalled();
738
+ });
739
+
740
+ it('re-stores a new pendingFeedbackRunID when a resumed run pauses again', async () => {
741
+ const session = makeSessionEntity({
742
+ Config_: JSON.stringify({ targetAgentID: 'target-1', pendingFeedbackRunID: 'paused-1' }),
743
+ });
744
+ currentProvider = makeProvider(() => session);
745
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"q":2}', Success: true, PausedRunID: 'paused-2' });
746
+ const resolver = makeResolver();
747
+
748
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-3', 'invoke-target-agent', '{"request":"yes"}', makeCtx(), makePubSub());
749
+
750
+ const relayArg = executeRelayedToolMock.mock.calls[0][0] as { ResumeRunID?: string };
751
+ expect(relayArg.ResumeRunID).toBe('paused-1');
752
+ expect(JSON.parse(session.Config_ as string).pendingFeedbackRunID).toBe('paused-2');
753
+ });
754
+
755
+ it('skips the session save entirely when there was no pending run and the call did not pause (no write storm)', async () => {
756
+ const session = makeSessionEntity({ Config_: JSON.stringify({ targetAgentID: 'target-1' }) });
757
+ currentProvider = makeProvider(() => session);
758
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
759
+ const resolver = makeResolver();
760
+
761
+ const out = await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
762
+
763
+ expect(out).toBe('{"ok":true}');
764
+ expect(session.Save).not.toHaveBeenCalled(); // updatePendingFeedbackRunID no-op path
765
+ });
766
+
767
+ it('still returns the tool result when persisting the pendingFeedbackRunID fails (best-effort)', async () => {
768
+ const session = makeSessionEntity({
769
+ Config_: JSON.stringify({ targetAgentID: 'target-1' }),
770
+ Save: vi.fn(async () => false),
771
+ LatestResult: { CompleteMessage: 'db down' },
772
+ });
773
+ currentProvider = makeProvider(() => session);
774
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"q":1}', Success: true, PausedRunID: 'paused-1' });
775
+ const resolver = makeResolver();
776
+
777
+ const out = await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
778
+
779
+ expect(out).toBe('{"q":1}');
780
+ expect(heartbeatMock).toHaveBeenCalled(); // the relay flow completed despite the failed save
781
+ });
782
+
783
+ it('rejects (no tool execution) when the session config is missing entirely', async () => {
784
+ currentProvider = makeProvider(() => makeSessionEntity({ Config_: null }));
785
+ const resolver = makeResolver();
786
+
787
+ await expect(
788
+ resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'x', '{}', makeCtx(), makePubSub()),
789
+ ).rejects.toThrow(/no target agent configured/i);
790
+ expect(executeRelayedToolMock).not.toHaveBeenCalled();
791
+ });
792
+
793
+ it('rejects when the session config is malformed JSON', async () => {
794
+ currentProvider = makeProvider(() => makeSessionEntity({ Config_: '{not valid json' }));
795
+ const resolver = makeResolver();
796
+
797
+ await expect(
798
+ resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'x', '{}', makeCtx(), makePubSub()),
799
+ ).rejects.toThrow(/no target agent configured/i);
800
+ });
801
+
802
+ it('rejects when the session config parses but carries no targetAgentID', async () => {
803
+ currentProvider = makeProvider(() => makeSessionEntity({ Config_: JSON.stringify({ coAgentRunID: 'co-1' }) }));
804
+ const resolver = makeResolver();
805
+
806
+ await expect(
807
+ resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'x', '{}', makeCtx(), makePubSub()),
808
+ ).rejects.toThrow(/no target agent configured/i);
809
+ });
810
+ });
811
+
812
+ describe('RealtimeClientSessionResolver.RelayRealtimeTranscript', () => {
813
+ it('persists a Conversation Detail stamped with the session conversation + role mapping', async () => {
814
+ const detail = makeSessionEntity({ ID: 'detail-1' });
815
+ const session = makeSessionEntity({ ConversationID: 'conv-7' });
816
+ currentProvider = makeProvider((name) =>
817
+ name === 'MJ: Conversation Details' ? detail : session,
818
+ );
819
+ const resolver = makeResolver();
820
+
821
+ const ok = await resolver.RelayRealtimeTranscript('session-1', 'user', 'hello there', makeCtx());
822
+
823
+ expect(ok).toBe(true);
824
+ expect(detail.NewRecord).toHaveBeenCalled();
825
+ expect(detail.ConversationID).toBe('conv-7');
826
+ expect(detail.Role).toBe('User');
827
+ expect(detail.Message).toBe('hello there');
828
+ expect(detail.AgentSessionID).toBe('session-1');
829
+ expect(detail.UserID).toBe('user-1');
830
+ expect(detail.Save).toHaveBeenCalled();
831
+ expect(heartbeatMock).toHaveBeenCalledWith('session-1', USER, currentProvider);
832
+ });
833
+
834
+ it("maps a non-user role to 'AI'", async () => {
835
+ const detail = makeSessionEntity({ ID: 'detail-2' });
836
+ const session = makeSessionEntity();
837
+ currentProvider = makeProvider((name) =>
838
+ name === 'MJ: Conversation Details' ? detail : session,
839
+ );
840
+ const resolver = makeResolver();
841
+
842
+ await resolver.RelayRealtimeTranscript('session-1', 'assistant', 'response', makeCtx());
843
+ expect(detail.Role).toBe('AI');
844
+ });
845
+
846
+ it("maps role case/whitespace-insensitively (' USER ' → 'User')", async () => {
847
+ const detail = makeSessionEntity({ ID: 'detail-3' });
848
+ const session = makeSessionEntity();
849
+ currentProvider = makeProvider((name) =>
850
+ name === 'MJ: Conversation Details' ? detail : session,
851
+ );
852
+ const resolver = makeResolver();
853
+
854
+ await resolver.RelayRealtimeTranscript('session-1', ' USER ', 'hello', makeCtx());
855
+ expect(detail.Role).toBe('User');
856
+ });
857
+
858
+ it('returns false WITHOUT heartbeating when the detail save fails', async () => {
859
+ const detail = makeSessionEntity({
860
+ ID: 'detail-fail',
861
+ Save: vi.fn(async () => false),
862
+ LatestResult: { CompleteMessage: 'constraint violation' },
863
+ });
864
+ const session = makeSessionEntity();
865
+ currentProvider = makeProvider((name) =>
866
+ name === 'MJ: Conversation Details' ? detail : session,
867
+ );
868
+ const resolver = makeResolver();
869
+
870
+ const ok = await resolver.RelayRealtimeTranscript('session-1', 'user', 'hello', makeCtx());
871
+
872
+ expect(ok).toBe(false);
873
+ expect(heartbeatMock).not.toHaveBeenCalled();
874
+ });
875
+
876
+ it('rejects a closed session (transcript relay requires an open session)', async () => {
877
+ currentProvider = makeProvider(() => makeSessionEntity({ Status: 'Closed' }));
878
+ const resolver = makeResolver();
879
+
880
+ await expect(
881
+ resolver.RelayRealtimeTranscript('session-1', 'user', 'hello', makeCtx()),
882
+ ).rejects.toThrow(/closed/i);
883
+ });
884
+
885
+ it('enforces ownership — rejects when the caller does not own the session', async () => {
886
+ currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'someone-else' }));
887
+ const resolver = makeResolver();
888
+
889
+ await expect(
890
+ resolver.RelayRealtimeTranscript('session-1', 'user', 'hello', makeCtx()),
891
+ ).rejects.toThrow(/do not own/i);
892
+ expect(heartbeatMock).not.toHaveBeenCalled();
893
+ });
894
+
895
+ describe('replacesPrevious (correction update-in-place)', () => {
896
+ it('UPDATES the most recent same-role turn in place instead of appending', async () => {
897
+ const previous = makeSessionEntity({ ID: 'detail-prev', Message: 'truncated turn that', Role: 'AI' });
898
+ const session = makeSessionEntity();
899
+ currentProvider = makeProvider(() => session);
900
+ (currentProvider as { RunView?: unknown }).RunView = vi.fn(async () => ({
901
+ Success: true,
902
+ Results: [previous],
903
+ }));
904
+ const resolver = makeResolver();
905
+
906
+ const ok = await resolver.RelayRealtimeTranscript('session-1', 'assistant', 'truncated turn', makeCtx(), true);
907
+
908
+ expect(ok).toBe(true);
909
+ expect(previous.Message).toBe('truncated turn'); // corrected IN PLACE
910
+ expect(previous.Save).toHaveBeenCalled();
911
+ expect(previous.NewRecord).not.toHaveBeenCalled(); // no duplicate row
912
+ expect(heartbeatMock).toHaveBeenCalled();
913
+ // the lookup scoped to THIS session + the mapped role, newest first
914
+ const params = (currentProvider as { RunView: ReturnType<typeof vi.fn> }).RunView.mock.calls[0][0] as {
915
+ ExtraFilter: string; OrderBy: string; MaxRows: number;
916
+ };
917
+ expect(params.ExtraFilter).toContain("AgentSessionID='session-1'");
918
+ expect(params.ExtraFilter).toContain("Role='AI'");
919
+ expect(params.OrderBy).toContain('__mj_CreatedAt DESC');
920
+ expect(params.MaxRows).toBe(1);
921
+ });
922
+
923
+ it('falls back to a plain INSERT when no prior same-role turn exists (correction never dropped)', async () => {
924
+ const detail = makeSessionEntity({ ID: 'detail-new' });
925
+ const session = makeSessionEntity({ ConversationID: 'conv-9' });
926
+ currentProvider = makeProvider((name) =>
927
+ name === 'MJ: Conversation Details' ? detail : session,
928
+ );
929
+ (currentProvider as { RunView?: unknown }).RunView = vi.fn(async () => ({ Success: true, Results: [] }));
930
+ const resolver = makeResolver();
931
+
932
+ const ok = await resolver.RelayRealtimeTranscript('session-1', 'assistant', 'orphan correction', makeCtx(), true);
933
+
934
+ expect(ok).toBe(true);
935
+ expect(detail.NewRecord).toHaveBeenCalled();
936
+ expect(detail.Message).toBe('orphan correction');
937
+ });
938
+
939
+ it('returns false WITHOUT heartbeating when the in-place save fails', async () => {
940
+ const previous = makeSessionEntity({
941
+ ID: 'detail-prev',
942
+ Save: vi.fn(async () => false),
943
+ LatestResult: { CompleteMessage: 'locked' },
944
+ });
945
+ currentProvider = makeProvider(() => makeSessionEntity());
946
+ (currentProvider as { RunView?: unknown }).RunView = vi.fn(async () => ({
947
+ Success: true,
948
+ Results: [previous],
949
+ }));
950
+ const resolver = makeResolver();
951
+
952
+ const ok = await resolver.RelayRealtimeTranscript('session-1', 'assistant', 'x', makeCtx(), true);
953
+
954
+ expect(ok).toBe(false);
955
+ expect(heartbeatMock).not.toHaveBeenCalled();
956
+ });
957
+ });
958
+ });
959
+
960
+ describe('RealtimeClientSessionResolver.StartRealtimeClientSession — clientToolsJson validation', () => {
961
+ const okPrep = {
962
+ Success: true,
963
+ ClientConfig: {
964
+ Provider: 'openai',
965
+ Model: 'gpt-realtime',
966
+ EphemeralToken: 'ek_abc',
967
+ ExpiresAt: '2026-01-01T00:00:00Z',
968
+ SessionConfig: {},
969
+ },
970
+ };
971
+
972
+ function setupHappyStart(): void {
973
+ hasPermissionMock.mockResolvedValue(true);
974
+ currentProvider = makeProvider(() => makeSessionEntity());
975
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-ct' }));
976
+ prepareClientSessionMock.mockResolvedValue(okPrep);
977
+ }
978
+
979
+ /** Starts a session with `clientToolsJson` and returns the ExtraTools the prepare received. */
980
+ async function startWithClientTools(clientToolsJson?: string): Promise<unknown> {
981
+ const resolver = makeResolver();
982
+ await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, undefined, undefined, clientToolsJson);
983
+ const prepArg = prepareClientSessionMock.mock.calls[0][0] as { ExtraTools?: unknown };
984
+ return prepArg.ExtraTools;
985
+ }
986
+
987
+ const validTool = { Name: 'Whiteboard.AddNote', Description: 'Add a sticky note', ParametersSchema: { type: 'object' } };
988
+
989
+ it('threads a valid declaration array through as ExtraTools (client-executed UI tools)', async () => {
990
+ setupHappyStart();
991
+ const extraTools = await startWithClientTools(JSON.stringify([validTool]));
992
+ expect(extraTools).toEqual([validTool]);
993
+ });
994
+
995
+ it('omits ExtraTools when no clientToolsJson is supplied', async () => {
996
+ setupHappyStart();
997
+ const extraTools = await startWithClientTools(undefined);
998
+ expect(extraTools).toBeUndefined();
999
+ });
1000
+
1001
+ it('tolerantly rejects non-JSON payloads (mints without client tools, never throws)', async () => {
1002
+ setupHappyStart();
1003
+ const extraTools = await startWithClientTools('not json at all {{{');
1004
+ expect(extraTools).toBeUndefined();
1005
+ });
1006
+
1007
+ it('rejects non-array JSON payloads', async () => {
1008
+ setupHappyStart();
1009
+ const extraTools = await startWithClientTools(JSON.stringify({ Name: 'x' }));
1010
+ expect(extraTools).toBeUndefined();
1011
+ });
1012
+
1013
+ it('rejects a declaration flood beyond the 64-tool cap wholesale', async () => {
1014
+ setupHappyStart();
1015
+ const flood = Array.from({ length: 65 }, (_, i) => ({ ...validTool, Name: `Tool.${i}` }));
1016
+ const extraTools = await startWithClientTools(JSON.stringify(flood));
1017
+ expect(extraTools).toBeUndefined();
1018
+ });
1019
+
1020
+ it('skips malformed entries (bad Name / Description / ParametersSchema) but keeps valid ones', async () => {
1021
+ setupHappyStart();
1022
+ const extraTools = await startWithClientTools(JSON.stringify([
1023
+ validTool,
1024
+ { Name: '', Description: 'no name', ParametersSchema: {} },
1025
+ { Name: 'NoDescription', ParametersSchema: {} },
1026
+ { Name: 'BadSchema', Description: 'schema is an array', ParametersSchema: [] },
1027
+ 'not-an-object',
1028
+ ]));
1029
+ expect(extraTools).toEqual([validTool]);
1030
+ });
1031
+
1032
+ it('rejects an oversized declarations payload wholesale', async () => {
1033
+ setupHappyStart();
1034
+ const huge = JSON.stringify([{ ...validTool, Description: 'x'.repeat(300_000) }]);
1035
+ const extraTools = await startWithClientTools(huge);
1036
+ expect(extraTools).toBeUndefined();
1037
+ });
1038
+ });
1039
+
1040
+ describe('RealtimeClientSessionResolver.SaveSessionChannelState', () => {
1041
+ const CHANNEL_ROW = { ID: 'channel-wb', IsActive: true };
1042
+
1043
+ /**
1044
+ * Provider with GetEntityObject (session load + new session-channel rows) and RunView
1045
+ * (the session-channel upsert lookup — transactional, deliberately still RunView) under
1046
+ * test control. Channel DEFINITIONS come from the mocked engine cache (`channelRows`,
1047
+ * served as `MJ: AI Agent Channels` rows named 'Whiteboard').
1048
+ */
1049
+ function makeChannelProvider(opts: {
1050
+ session?: FakeSession;
1051
+ channelRows?: Array<{ ID: string; IsActive: boolean }>;
1052
+ sessionChannelRows?: FakeSession[];
1053
+ newSessionChannel?: FakeSession;
1054
+ }): { provider: unknown; runView: ReturnType<typeof vi.fn>; newRow: FakeSession } {
1055
+ const newRow = opts.newSessionChannel ?? makeSessionEntity({ ID: 'sc-new' });
1056
+ engineChannelsMock.mockReturnValue(
1057
+ (opts.channelRows ?? []).map((r) => ({ Name: 'Whiteboard', ...r })),
1058
+ );
1059
+ const runView = vi.fn(async () => ({ Success: true, Results: opts.sessionChannelRows ?? [] }));
1060
+ const provider = {
1061
+ GetEntityObject: vi.fn(async (name: string) =>
1062
+ name === 'MJ: AI Agent Session Channels' ? newRow : (opts.session ?? makeSessionEntity()),
1063
+ ),
1064
+ RunView: runView,
1065
+ };
1066
+ return { provider, runView, newRow };
1067
+ }
1068
+
1069
+ it('enforces ownership — rejects when the caller does not own the session', async () => {
1070
+ const { provider } = makeChannelProvider({ session: makeSessionEntity({ UserID: 'someone-else' }) });
1071
+ currentProvider = provider;
1072
+ const resolver = makeResolver();
1073
+
1074
+ await expect(
1075
+ resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{}', makeCtx()),
1076
+ ).rejects.toThrow(/do not own/i);
1077
+ });
1078
+
1079
+ it('accepts a CLOSED session (the final on-end flush lands after close)', async () => {
1080
+ const { provider, newRow } = makeChannelProvider({
1081
+ session: makeSessionEntity({ Status: 'Closed' }),
1082
+ channelRows: [CHANNEL_ROW],
1083
+ });
1084
+ currentProvider = provider;
1085
+ const resolver = makeResolver();
1086
+
1087
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{"v":1}', makeCtx());
1088
+ expect(ok).toBe(true);
1089
+ expect(newRow.Save).toHaveBeenCalled();
1090
+ });
1091
+
1092
+ it('returns false gracefully (no throw) when no channel definition row exists', async () => {
1093
+ const { provider, newRow } = makeChannelProvider({ channelRows: [] });
1094
+ currentProvider = provider;
1095
+ const resolver = makeResolver();
1096
+
1097
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{}', makeCtx());
1098
+ expect(ok).toBe(false);
1099
+ expect(newRow.Save).not.toHaveBeenCalled();
1100
+ });
1101
+
1102
+ it('returns false when the channel definition is inactive', async () => {
1103
+ const { provider } = makeChannelProvider({ channelRows: [{ ID: 'channel-wb', IsActive: false }] });
1104
+ currentProvider = provider;
1105
+ const resolver = makeResolver();
1106
+
1107
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{}', makeCtx());
1108
+ expect(ok).toBe(false);
1109
+ });
1110
+
1111
+ it('creates the session-channel row (Status Connected) and stores the state on first save', async () => {
1112
+ const { provider, newRow } = makeChannelProvider({ channelRows: [CHANNEL_ROW] });
1113
+ currentProvider = provider;
1114
+ const resolver = makeResolver();
1115
+
1116
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{"items":[]}', makeCtx());
1117
+
1118
+ expect(ok).toBe(true);
1119
+ expect(newRow.NewRecord).toHaveBeenCalled();
1120
+ expect(newRow.AgentSessionID).toBe('session-1');
1121
+ expect(newRow.ChannelID).toBe('channel-wb');
1122
+ expect(newRow.Status).toBe('Connected');
1123
+ expect(newRow.Config_).toBe('{"items":[]}');
1124
+ expect(newRow.LastActiveAt).toBeInstanceOf(Date);
1125
+ expect(newRow.Save).toHaveBeenCalled();
1126
+ });
1127
+
1128
+ it('updates the EXISTING session-channel row on later saves (no duplicate row)', async () => {
1129
+ const existing = makeSessionEntity({ ID: 'sc-existing', Status: 'Connected' });
1130
+ const { provider, newRow } = makeChannelProvider({
1131
+ channelRows: [CHANNEL_ROW],
1132
+ sessionChannelRows: [existing],
1133
+ });
1134
+ currentProvider = provider;
1135
+ const resolver = makeResolver();
1136
+
1137
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{"items":[1]}', makeCtx());
1138
+
1139
+ expect(ok).toBe(true);
1140
+ expect(existing.Config_).toBe('{"items":[1]}');
1141
+ expect(existing.Save).toHaveBeenCalled();
1142
+ // The fresh-row path was never taken.
1143
+ expect(newRow.NewRecord).not.toHaveBeenCalled();
1144
+ expect(newRow.Save).not.toHaveBeenCalled();
1145
+ });
1146
+
1147
+ it('rejects an oversized state blob (false, nothing queried/saved)', async () => {
1148
+ const { provider, runView, newRow } = makeChannelProvider({ channelRows: [CHANNEL_ROW] });
1149
+ currentProvider = provider;
1150
+ const resolver = makeResolver();
1151
+
1152
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', 'x'.repeat(2_000_001), makeCtx());
1153
+ expect(ok).toBe(false);
1154
+ expect(runView).not.toHaveBeenCalled();
1155
+ expect(newRow.Save).not.toHaveBeenCalled();
1156
+ });
1157
+
1158
+ it('returns false (logged, not thrown) when the row save fails', async () => {
1159
+ const failingRow = makeSessionEntity({
1160
+ ID: 'sc-fail',
1161
+ Save: vi.fn(async () => false),
1162
+ LatestResult: { CompleteMessage: 'boom' },
1163
+ });
1164
+ const { provider } = makeChannelProvider({ channelRows: [CHANNEL_ROW], newSessionChannel: failingRow });
1165
+ currentProvider = provider;
1166
+ const resolver = makeResolver();
1167
+
1168
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{}', makeCtx());
1169
+ expect(ok).toBe(false);
1170
+ });
1171
+
1172
+ it('routes the payload through the channel server plugin host and persists its normalization', async () => {
1173
+ onChannelStateSaveMock.mockResolvedValue('{"normalized":true}');
1174
+ const { provider, newRow } = makeChannelProvider({ channelRows: [CHANNEL_ROW] });
1175
+ currentProvider = provider;
1176
+ const resolver = makeResolver();
1177
+
1178
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{ "raw" : true }', makeCtx());
1179
+
1180
+ expect(ok).toBe(true);
1181
+ expect(onChannelStateSaveMock).toHaveBeenCalledWith('session-1', 'Whiteboard', '{ "raw" : true }');
1182
+ expect(newRow.Config_).toBe('{"normalized":true}');
1183
+ });
1184
+
1185
+ it('persists the ORIGINAL payload when the plugin host throws (plugins never block a save)', async () => {
1186
+ onChannelStateSaveMock.mockRejectedValue(new Error('plugin boom'));
1187
+ const { provider, newRow } = makeChannelProvider({ channelRows: [CHANNEL_ROW] });
1188
+ currentProvider = provider;
1189
+ const resolver = makeResolver();
1190
+
1191
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{"orig":1}', makeCtx());
1192
+
1193
+ expect(ok).toBe(true);
1194
+ expect(newRow.Config_).toBe('{"orig":1}');
1195
+ });
1196
+
1197
+ it('persists the ORIGINAL payload when the plugin returns an oversized replacement', async () => {
1198
+ onChannelStateSaveMock.mockResolvedValue('x'.repeat(2_000_001));
1199
+ const { provider, newRow } = makeChannelProvider({ channelRows: [CHANNEL_ROW] });
1200
+ currentProvider = provider;
1201
+ const resolver = makeResolver();
1202
+
1203
+ const ok = await resolver.SaveSessionChannelState('session-1', 'Whiteboard', '{"orig":2}', makeCtx());
1204
+
1205
+ expect(ok).toBe(true);
1206
+ expect(newRow.Config_).toBe('{"orig":2}');
1207
+ });
1208
+ });
1209
+
1210
+ describe('RealtimeClientSessionResolver.StartRealtimeClientSession — prior channel-state restore', () => {
1211
+ const okPrep = {
1212
+ Success: true,
1213
+ ClientConfig: {
1214
+ Provider: 'openai',
1215
+ Model: 'gpt-realtime',
1216
+ EphemeralToken: 'ek_abc',
1217
+ ExpiresAt: '2026-01-01T00:00:00Z',
1218
+ SessionConfig: {},
1219
+ },
1220
+ };
1221
+
1222
+ /**
1223
+ * Provider for the restore path: GetEntityObject serves the PRIOR session (the only entity
1224
+ * the start flow loads directly), RunView serves the prior session-channel rows. Pairing
1225
+ * rows come from the mocked engine cache (empty by default — universal co-agent, pairing
1226
+ * not under test here).
1227
+ */
1228
+ function makeRestoreProvider(opts: {
1229
+ prior?: FakeSession;
1230
+ rows?: Array<{ Channel: string; Config: string | null }>;
1231
+ runViewResult?: { Success: boolean; ErrorMessage?: string; Results?: unknown[] };
1232
+ }): { provider: unknown; runView: ReturnType<typeof vi.fn>; prior: FakeSession } {
1233
+ const prior = opts.prior ?? makeSessionEntity({ ID: 'prior-1', UserID: 'user-1' });
1234
+ const runView = vi.fn(async () => opts.runViewResult ?? { Success: true, Results: opts.rows ?? [] });
1235
+ return { provider: { GetEntityObject: vi.fn(async () => prior), RunView: runView }, runView, prior };
1236
+ }
1237
+
1238
+ function setupHappyStart(): void {
1239
+ hasPermissionMock.mockResolvedValue(true);
1240
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-restore' }));
1241
+ prepareClientSessionMock.mockResolvedValue(okPrep);
1242
+ }
1243
+
1244
+ it('returns the prior session channel states keyed by channel NAME (empty configs skipped)', async () => {
1245
+ setupHappyStart();
1246
+ const { provider } = makeRestoreProvider({
1247
+ rows: [
1248
+ { Channel: 'Whiteboard', Config: '{"items":[1,2]}' },
1249
+ { Channel: 'EmptyChannel', Config: null },
1250
+ { Channel: 'Notes', Config: '{"text":"hi"}' },
1251
+ ],
1252
+ });
1253
+ currentProvider = provider;
1254
+ const resolver = makeResolver();
1255
+
1256
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1257
+
1258
+ expect(result.PriorChannelStatesJson).toBeDefined();
1259
+ expect(JSON.parse(result.PriorChannelStatesJson as string)).toEqual({
1260
+ Whiteboard: '{"items":[1,2]}',
1261
+ Notes: '{"text":"hi"}',
1262
+ });
1263
+ // lastSessionId still flows into the new durable session record.
1264
+ const createArg = createSessionMock.mock.calls[0][0] as { lastSessionID?: string };
1265
+ expect(createArg.lastSessionID).toBe('prior-1');
1266
+ });
1267
+
1268
+ /** RunView calls that hit the session-channel restore query (the only RunView in the start flow). */
1269
+ function restoreQueryCalls(runView: ReturnType<typeof vi.fn>): unknown[] {
1270
+ return runView.mock.calls;
1271
+ }
1272
+
1273
+ it('omits the field when no lastSessionId is supplied (no restore query at all)', async () => {
1274
+ setupHappyStart();
1275
+ const { provider, runView } = makeRestoreProvider({ rows: [{ Channel: 'Whiteboard', Config: '{}' }] });
1276
+ currentProvider = provider;
1277
+ const resolver = makeResolver();
1278
+
1279
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx());
1280
+
1281
+ expect(result.PriorChannelStatesJson).toBeUndefined();
1282
+ expect(restoreQueryCalls(runView)).toHaveLength(0);
1283
+ });
1284
+
1285
+ it('omits the field (no state leak, no query) when the prior session belongs to ANOTHER user', async () => {
1286
+ setupHappyStart();
1287
+ const { provider, runView } = makeRestoreProvider({
1288
+ prior: makeSessionEntity({ ID: 'prior-1', UserID: 'someone-else' }),
1289
+ rows: [{ Channel: 'Whiteboard', Config: '{"secret":true}' }],
1290
+ });
1291
+ currentProvider = provider;
1292
+ const resolver = makeResolver();
1293
+
1294
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1295
+
1296
+ expect(result.PriorChannelStatesJson).toBeUndefined();
1297
+ expect(restoreQueryCalls(runView)).toHaveLength(0);
1298
+ });
1299
+
1300
+ it('omits the field when the prior session does not exist', async () => {
1301
+ setupHappyStart();
1302
+ const { provider, runView } = makeRestoreProvider({
1303
+ prior: makeSessionEntity({ Load: vi.fn(async () => false) }),
1304
+ });
1305
+ currentProvider = provider;
1306
+ const resolver = makeResolver();
1307
+
1308
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-gone');
1309
+
1310
+ expect(result.PriorChannelStatesJson).toBeUndefined();
1311
+ expect(restoreQueryCalls(runView)).toHaveLength(0);
1312
+ });
1313
+
1314
+ it('omits the field when the prior session has no channel rows / no non-empty configs', async () => {
1315
+ setupHappyStart();
1316
+ const { provider } = makeRestoreProvider({ rows: [{ Channel: 'Whiteboard', Config: null }] });
1317
+ currentProvider = provider;
1318
+ const resolver = makeResolver();
1319
+
1320
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1321
+ expect(result.PriorChannelStatesJson).toBeUndefined();
1322
+ });
1323
+
1324
+ it('drops an oversized individual state (beyond the 2MB cap) but restores the rest', async () => {
1325
+ setupHappyStart();
1326
+ const { provider } = makeRestoreProvider({
1327
+ rows: [
1328
+ { Channel: 'Huge', Config: 'x'.repeat(2_000_001) },
1329
+ { Channel: 'Whiteboard', Config: '{"items":[]}' },
1330
+ ],
1331
+ });
1332
+ currentProvider = provider;
1333
+ const resolver = makeResolver();
1334
+
1335
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1336
+
1337
+ expect(JSON.parse(result.PriorChannelStatesJson as string)).toEqual({ Whiteboard: '{"items":[]}' });
1338
+ });
1339
+
1340
+ it('drops states that would push the ACCUMULATED restore payload past the 2MB cap', async () => {
1341
+ setupHappyStart();
1342
+ const { provider } = makeRestoreProvider({
1343
+ rows: [
1344
+ { Channel: 'First', Config: 'a'.repeat(1_500_000) },
1345
+ { Channel: 'Second', Config: 'b'.repeat(1_500_000) }, // would exceed the total cap
1346
+ ],
1347
+ });
1348
+ currentProvider = provider;
1349
+ const resolver = makeResolver();
1350
+
1351
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1352
+
1353
+ const states = JSON.parse(result.PriorChannelStatesJson as string) as Record<string, string>;
1354
+ expect(Object.keys(states)).toEqual(['First']);
1355
+ });
1356
+
1357
+ it('tolerates a failed restore query (start succeeds, field omitted)', async () => {
1358
+ setupHappyStart();
1359
+ const { provider } = makeRestoreProvider({ runViewResult: { Success: false, ErrorMessage: 'db down' } });
1360
+ currentProvider = provider;
1361
+ const resolver = makeResolver();
1362
+
1363
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1364
+
1365
+ expect(result.AgentSessionId).toBe('session-restore');
1366
+ expect(result.PriorChannelStatesJson).toBeUndefined();
1367
+ });
1368
+
1369
+ it('tolerates a thrown restore failure (start succeeds, field omitted)', async () => {
1370
+ setupHappyStart();
1371
+ currentProvider = {
1372
+ GetEntityObject: vi.fn(async () => {
1373
+ throw new Error('provider exploded');
1374
+ }),
1375
+ RunView: vi.fn(),
1376
+ };
1377
+ const resolver = makeResolver();
1378
+
1379
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1380
+
1381
+ expect(result.AgentSessionId).toBe('session-restore');
1382
+ expect(result.PriorChannelStatesJson).toBeUndefined();
1383
+ });
1384
+ });
1385
+
1386
+ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
1387
+ const WHITEBOARD_TYPE_ROW = { ID: 'type-wb', IsEnabled: true };
1388
+ const CHANNEL_ROW = { ID: 'channel-wb', IsActive: true };
1389
+
1390
+ /**
1391
+ * Provider for the artifact path: GetEntityObject routes by entity name (session / artifact /
1392
+ * version / junction), RunView routes by EntityName (artifact types / conversation details /
1393
+ * session-channel rows). Channel DEFINITIONS come from the mocked engine cache
1394
+ * (`channelRows`, served as `MJ: AI Agent Channels` rows named 'Whiteboard').
1395
+ */
1396
+ function makeArtifactProvider(opts: {
1397
+ session?: FakeSession;
1398
+ typeRows?: Array<{ ID: string; IsEnabled: boolean }>;
1399
+ detailRows?: Array<{ ID: string }>;
1400
+ channelRows?: Array<{ ID: string; IsActive: boolean }>;
1401
+ sessionChannelRows?: FakeSession[];
1402
+ artifact?: FakeSession;
1403
+ version?: FakeSession;
1404
+ junction?: FakeSession;
1405
+ }): {
1406
+ provider: unknown;
1407
+ runView: ReturnType<typeof vi.fn>;
1408
+ artifact: FakeSession;
1409
+ version: FakeSession;
1410
+ junction: FakeSession;
1411
+ } {
1412
+ const artifact = opts.artifact ?? makeSessionEntity({ ID: 'artifact-1' });
1413
+ const version = opts.version ?? makeSessionEntity({ ID: 'version-1' });
1414
+ const junction = opts.junction ?? makeSessionEntity({ ID: 'junction-1' });
1415
+ const session = opts.session ?? makeSessionEntity();
1416
+ engineChannelsMock.mockReturnValue(
1417
+ (opts.channelRows ?? []).map((r) => ({ Name: 'Whiteboard', ...r })),
1418
+ );
1419
+ const runView = vi.fn(async (params: { EntityName: string }) => {
1420
+ switch (params.EntityName) {
1421
+ case 'MJ: Artifact Types':
1422
+ return { Success: true, Results: opts.typeRows ?? [] };
1423
+ case 'MJ: Conversation Details':
1424
+ return { Success: true, Results: opts.detailRows ?? [] };
1425
+ default: // MJ: AI Agent Session Channels
1426
+ return { Success: true, Results: opts.sessionChannelRows ?? [] };
1427
+ }
1428
+ });
1429
+ const provider = {
1430
+ GetEntityObject: vi.fn(async (name: string) => {
1431
+ switch (name) {
1432
+ case 'MJ: Artifacts':
1433
+ return artifact;
1434
+ case 'MJ: Artifact Versions':
1435
+ return version;
1436
+ case 'MJ: Conversation Detail Artifacts':
1437
+ return junction;
1438
+ default:
1439
+ return session;
1440
+ }
1441
+ }),
1442
+ RunView: runView,
1443
+ };
1444
+ return { provider, runView, artifact, version, junction };
1445
+ }
1446
+
1447
+ it('enforces ownership — rejects when the caller does not own the session', async () => {
1448
+ const { provider, artifact } = makeArtifactProvider({ session: makeSessionEntity({ UserID: 'someone-else' }) });
1449
+ currentProvider = provider;
1450
+ const resolver = makeResolver();
1451
+
1452
+ await expect(
1453
+ resolver.SaveSessionChannelArtifact('session-1', 'Whiteboard', 'My Board', '{}', makeCtx()),
1454
+ ).rejects.toThrow(/do not own/i);
1455
+ expect(artifact.Save).not.toHaveBeenCalled();
1456
+ });
1457
+
1458
+ it('fails gracefully (structured, no throw) when the Whiteboard artifact type is unseeded', async () => {
1459
+ const { provider, artifact } = makeArtifactProvider({ typeRows: [] });
1460
+ currentProvider = provider;
1461
+ const resolver = makeResolver();
1462
+
1463
+ const result = await resolver.SaveSessionChannelArtifact('session-1', 'Whiteboard', 'My Board', '{}', makeCtx());
1464
+
1465
+ expect(result.Success).toBe(false);
1466
+ expect(result.ErrorMessage).toMatch(/Whiteboard.*artifact type/i);
1467
+ expect(result.ArtifactID).toBeUndefined();
1468
+ expect(artifact.Save).not.toHaveBeenCalled();
1469
+ });
1470
+
1471
+ it('fails gracefully when the Whiteboard artifact type exists but is disabled', async () => {
1472
+ const { provider } = makeArtifactProvider({ typeRows: [{ ID: 'type-wb', IsEnabled: false }] });
1473
+ currentProvider = provider;
1474
+ const resolver = makeResolver();
1475
+
1476
+ const result = await resolver.SaveSessionChannelArtifact('session-1', 'Whiteboard', 'My Board', '{}', makeCtx());
1477
+ expect(result.Success).toBe(false);
1478
+ });
1479
+
1480
+ it('creates the artifact + v1 version with the right fields and links it into conversation history', async () => {
1481
+ const sessionChannelRow = makeSessionEntity({ ID: 'sc-1' });
1482
+ const { provider, artifact, version, junction } = makeArtifactProvider({
1483
+ session: makeSessionEntity({ ID: 'session-1', ConversationID: 'conv-1' }),
1484
+ typeRows: [WHITEBOARD_TYPE_ROW],
1485
+ detailRows: [{ ID: 'detail-9' }],
1486
+ channelRows: [CHANNEL_ROW],
1487
+ sessionChannelRows: [sessionChannelRow],
1488
+ });
1489
+ currentProvider = provider;
1490
+ const resolver = makeResolver();
1491
+
1492
+ const result = await resolver.SaveSessionChannelArtifact(
1493
+ 'session-1', 'Whiteboard', 'Q3 Planning Board', '{"shapes":[]}', makeCtx(),
1494
+ );
1495
+
1496
+ // Structured success with both ids.
1497
+ expect(result).toEqual({
1498
+ Success: true,
1499
+ ArtifactID: 'artifact-1',
1500
+ ArtifactVersionID: 'version-1',
1501
+ ConversationDetailLinked: true,
1502
+ });
1503
+ // Artifact header — user-owned, typed, visible, described with session + channel.
1504
+ expect(artifact.NewRecord).toHaveBeenCalled();
1505
+ expect(artifact.Name).toBe('Q3 Planning Board');
1506
+ expect(artifact.TypeID).toBe('type-wb');
1507
+ expect(artifact.UserID).toBe('user-1');
1508
+ expect(artifact.Visibility).toBe('Always');
1509
+ expect(artifact.Description).toContain('session-1');
1510
+ expect(artifact.Description).toContain('Whiteboard');
1511
+ expect(artifact.Save).toHaveBeenCalled();
1512
+ // Version 1 carries the content.
1513
+ expect(version.ArtifactID).toBe('artifact-1');
1514
+ expect(version.VersionNumber).toBe(1);
1515
+ expect(version.Content).toBe('{"shapes":[]}');
1516
+ expect(version.UserID).toBe('user-1');
1517
+ expect(version.Save).toHaveBeenCalled();
1518
+ // Junction against the LATEST session-stamped conversation detail, the way chat links.
1519
+ expect(junction.ConversationDetailID).toBe('detail-9');
1520
+ expect(junction.ArtifactVersionID).toBe('version-1');
1521
+ expect(junction.Direction).toBe('Output');
1522
+ expect(junction.Save).toHaveBeenCalled();
1523
+ // The session-channel row got its LastActiveAt stamped (saving IS channel activity).
1524
+ expect(sessionChannelRow.LastActiveAt).toBeInstanceOf(Date);
1525
+ expect(sessionChannelRow.Save).toHaveBeenCalled();
1526
+ });
1527
+
1528
+ it('skips the junction silently (still Success) when no conversation detail is stamped with the session', async () => {
1529
+ const { provider, junction } = makeArtifactProvider({
1530
+ session: makeSessionEntity({ ConversationID: 'conv-1' }),
1531
+ typeRows: [WHITEBOARD_TYPE_ROW],
1532
+ detailRows: [],
1533
+ });
1534
+ currentProvider = provider;
1535
+ const resolver = makeResolver();
1536
+
1537
+ const result = await resolver.SaveSessionChannelArtifact('session-1', 'Whiteboard', 'Board', '{}', makeCtx());
1538
+
1539
+ expect(result.Success).toBe(true);
1540
+ expect(result.ConversationDetailLinked).toBe(false);
1541
+ expect(junction.Save).not.toHaveBeenCalled();
1542
+ });
1543
+
1544
+ it('skips the junction (no detail query) when the session has no conversation at all', async () => {
1545
+ const { provider, runView, junction } = makeArtifactProvider({
1546
+ session: makeSessionEntity({ ConversationID: null }),
1547
+ typeRows: [WHITEBOARD_TYPE_ROW],
1548
+ });
1549
+ currentProvider = provider;
1550
+ const resolver = makeResolver();
1551
+
1552
+ const result = await resolver.SaveSessionChannelArtifact('session-1', 'Whiteboard', 'Board', '{}', makeCtx());
1553
+
1554
+ expect(result.Success).toBe(true);
1555
+ expect(result.ConversationDetailLinked).toBe(false);
1556
+ expect(junction.Save).not.toHaveBeenCalled();
1557
+ const queriedEntities = runView.mock.calls.map((c) => (c[0] as { EntityName: string }).EntityName);
1558
+ expect(queriedEntities).not.toContain('MJ: Conversation Details');
1559
+ });
1560
+
1561
+ it('still succeeds (linked=false, logged) when the junction save fails — link is best-effort', async () => {
1562
+ const failingJunction = makeSessionEntity({
1563
+ ID: 'junction-fail',
1564
+ Save: vi.fn(async () => false),
1565
+ LatestResult: { CompleteMessage: 'fk violation' },
1566
+ });
1567
+ const { provider } = makeArtifactProvider({
1568
+ session: makeSessionEntity({ ConversationID: 'conv-1' }),
1569
+ typeRows: [WHITEBOARD_TYPE_ROW],
1570
+ detailRows: [{ ID: 'detail-9' }],
1571
+ junction: failingJunction,
1572
+ });
1573
+ currentProvider = provider;
1574
+ const resolver = makeResolver();
1575
+
1576
+ const result = await resolver.SaveSessionChannelArtifact('session-1', 'Whiteboard', 'Board', '{}', makeCtx());
1577
+
1578
+ expect(result.Success).toBe(true);
1579
+ expect(result.ConversationDetailLinked).toBe(false);
1580
+ });
1581
+
1582
+ it('rejects oversized content (structured failure, nothing queried or saved)', async () => {
1583
+ const { provider, runView, artifact } = makeArtifactProvider({ typeRows: [WHITEBOARD_TYPE_ROW] });
1584
+ currentProvider = provider;
1585
+ const resolver = makeResolver();
1586
+
1587
+ const result = await resolver.SaveSessionChannelArtifact(
1588
+ 'session-1', 'Whiteboard', 'Board', 'x'.repeat(2_000_001), makeCtx(),
1589
+ );
1590
+
1591
+ expect(result.Success).toBe(false);
1592
+ expect(result.ErrorMessage).toMatch(/oversized/i);
1593
+ expect(runView).not.toHaveBeenCalled();
1594
+ expect(artifact.Save).not.toHaveBeenCalled();
1595
+ });
1596
+
1597
+ it('returns a structured failure when the artifact header save fails (no version attempted)', async () => {
1598
+ const failingArtifact = makeSessionEntity({
1599
+ ID: 'artifact-fail',
1600
+ Save: vi.fn(async () => false),
1601
+ LatestResult: { CompleteMessage: 'db down' },
1602
+ });
1603
+ const { provider, version } = makeArtifactProvider({
1604
+ typeRows: [WHITEBOARD_TYPE_ROW],
1605
+ artifact: failingArtifact,
1606
+ });
1607
+ currentProvider = provider;
1608
+ const resolver = makeResolver();
1609
+
1610
+ const result = await resolver.SaveSessionChannelArtifact('session-1', 'Whiteboard', 'Board', '{}', makeCtx());
1611
+
1612
+ expect(result.Success).toBe(false);
1613
+ expect(result.ErrorMessage).toMatch(/artifact save failed/i);
1614
+ expect(version.Save).not.toHaveBeenCalled();
1615
+ });
1616
+
1617
+ it('returns a structured failure (carrying the orphaned ArtifactID) when the version save fails', async () => {
1618
+ const failingVersion = makeSessionEntity({
1619
+ ID: 'version-fail',
1620
+ Save: vi.fn(async () => false),
1621
+ LatestResult: { CompleteMessage: 'too big' },
1622
+ });
1623
+ const { provider, junction } = makeArtifactProvider({
1624
+ typeRows: [WHITEBOARD_TYPE_ROW],
1625
+ detailRows: [{ ID: 'detail-9' }],
1626
+ version: failingVersion,
1627
+ });
1628
+ currentProvider = provider;
1629
+ const resolver = makeResolver();
1630
+
1631
+ const result = await resolver.SaveSessionChannelArtifact('session-1', 'Whiteboard', 'Board', '{}', makeCtx());
1632
+
1633
+ expect(result.Success).toBe(false);
1634
+ expect(result.ArtifactID).toBe('artifact-1');
1635
+ expect(result.ArtifactVersionID).toBeUndefined();
1636
+ expect(junction.Save).not.toHaveBeenCalled();
1637
+ });
1638
+
1639
+ it('accepts a CLOSED session ("save my board" legitimately lands after the call ends)', async () => {
1640
+ const { provider } = makeArtifactProvider({
1641
+ session: makeSessionEntity({ Status: 'Closed', ConversationID: null }),
1642
+ typeRows: [WHITEBOARD_TYPE_ROW],
1643
+ });
1644
+ currentProvider = provider;
1645
+ const resolver = makeResolver();
1646
+
1647
+ const result = await resolver.SaveSessionChannelArtifact('session-1', 'Whiteboard', 'Board', '{}', makeCtx());
1648
+ expect(result.Success).toBe(true);
1649
+ });
1650
+ });
1651
+
1652
+ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool — delegated-artifact history linking', () => {
1653
+ /**
1654
+ * Provider for the junction-linking path: GetEntityObject routes by entity name (session load /
1655
+ * junction rows / the hidden anchor detail), RunView serves the latest session-stamped detail
1656
+ * lookup. Collects every created junction + anchor entity for assertion.
1657
+ */
1658
+ function makeLinkProvider(opts: {
1659
+ session?: FakeSession;
1660
+ detailRows?: Array<{ ID: string }>;
1661
+ detailQueryFails?: boolean;
1662
+ }): {
1663
+ provider: unknown;
1664
+ session: FakeSession;
1665
+ junctions: FakeSession[];
1666
+ anchors: FakeSession[];
1667
+ runView: ReturnType<typeof vi.fn>;
1668
+ } {
1669
+ const session = opts.session ?? makeSessionEntity();
1670
+ const junctions: FakeSession[] = [];
1671
+ const anchors: FakeSession[] = [];
1672
+ const runView = vi.fn(async () =>
1673
+ opts.detailQueryFails
1674
+ ? { Success: false, ErrorMessage: 'db down', Results: [] }
1675
+ : { Success: true, Results: opts.detailRows ?? [] },
1676
+ );
1677
+ const provider = {
1678
+ GetEntityObject: vi.fn(async (name: string) => {
1679
+ if (name === 'MJ: AI Agent Sessions') {
1680
+ return session;
1681
+ }
1682
+ if (name === 'MJ: Conversation Detail Artifacts') {
1683
+ const junction = makeSessionEntity({ ID: `junction-${junctions.length + 1}` });
1684
+ junctions.push(junction);
1685
+ return junction;
1686
+ }
1687
+ if (name === 'MJ: Conversation Details') {
1688
+ const anchor = makeSessionEntity({ ID: 'anchor-detail-1' });
1689
+ anchors.push(anchor);
1690
+ return anchor;
1691
+ }
1692
+ return makeSessionEntity();
1693
+ }),
1694
+ RunView: runView,
1695
+ };
1696
+ return { provider, session, junctions, anchors, runView };
1697
+ }
1698
+
1699
+ const ARTIFACTS = [
1700
+ { ArtifactID: 'a-1', ArtifactVersionID: 'av-1', Name: 'Report' },
1701
+ { ArtifactID: 'a-2', ArtifactVersionID: 'av-2', Name: 'Chart' },
1702
+ ];
1703
+
1704
+ it('junction-links every delegated artifact version to the LATEST session-stamped detail', async () => {
1705
+ const { provider, junctions } = makeLinkProvider({ detailRows: [{ ID: 'detail-latest' }] });
1706
+ currentProvider = provider;
1707
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true, Artifacts: ARTIFACTS });
1708
+ const resolver = makeResolver();
1709
+
1710
+ const out = await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
1711
+
1712
+ expect(out).toBe('{"ok":true}');
1713
+ expect(junctions).toHaveLength(2);
1714
+ expect(junctions[0].ConversationDetailID).toBe('detail-latest');
1715
+ expect(junctions[0].ArtifactVersionID).toBe('av-1');
1716
+ expect(junctions[0].Direction).toBe('Output');
1717
+ expect(junctions[0].Save).toHaveBeenCalled();
1718
+ expect(junctions[1].ConversationDetailID).toBe('detail-latest');
1719
+ expect(junctions[1].ArtifactVersionID).toBe('av-2');
1720
+ // The relay still heartbeats afterward.
1721
+ expect(heartbeatMock).toHaveBeenCalledWith('session-1', USER, currentProvider);
1722
+ });
1723
+
1724
+ it('creates a HIDDEN anchor detail (Role AI, HiddenToUser, session-stamped) when no transcript turn exists yet', async () => {
1725
+ const { provider, junctions, anchors } = makeLinkProvider({ detailRows: [] });
1726
+ currentProvider = provider;
1727
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true, Artifacts: [ARTIFACTS[0]] });
1728
+ const resolver = makeResolver();
1729
+
1730
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
1731
+
1732
+ expect(anchors).toHaveLength(1);
1733
+ const anchor = anchors[0];
1734
+ expect(anchor.Role).toBe('AI');
1735
+ expect(anchor.HiddenToUser).toBe(true);
1736
+ expect(anchor.ConversationID).toBe('conv-1');
1737
+ expect(anchor.AgentSessionID).toBe('session-1');
1738
+ expect(anchor.UserID).toBe('user-1');
1739
+ expect(anchor.Save).toHaveBeenCalled();
1740
+ // The junction anchors to the freshly created hidden detail.
1741
+ expect(junctions).toHaveLength(1);
1742
+ expect(junctions[0].ConversationDetailID).toBe('anchor-detail-1');
1743
+ expect(junctions[0].ArtifactVersionID).toBe('av-1');
1744
+ });
1745
+
1746
+ it('skips linking entirely (no detail query, no junction) when the session has no conversation', async () => {
1747
+ const { provider, junctions, runView } = makeLinkProvider({
1748
+ session: makeSessionEntity({ ConversationID: null }),
1749
+ });
1750
+ currentProvider = provider;
1751
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true, Artifacts: ARTIFACTS });
1752
+ const resolver = makeResolver();
1753
+
1754
+ const out = await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
1755
+
1756
+ expect(out).toBe('{"ok":true}');
1757
+ expect(runView).not.toHaveBeenCalled();
1758
+ expect(junctions).toHaveLength(0);
1759
+ });
1760
+
1761
+ it('skips linking (no detail query) when the relayed result carries no artifacts', async () => {
1762
+ const { provider, junctions, runView } = makeLinkProvider({ detailRows: [{ ID: 'detail-1' }] });
1763
+ currentProvider = provider;
1764
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
1765
+ const resolver = makeResolver();
1766
+
1767
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
1768
+
1769
+ expect(runView).not.toHaveBeenCalled();
1770
+ expect(junctions).toHaveLength(0);
1771
+ });
1772
+
1773
+ it('tolerates a failed anchor lookup / junction save — the relayed result still returns', async () => {
1774
+ const { provider } = makeLinkProvider({ detailQueryFails: true });
1775
+ currentProvider = provider;
1776
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true, Artifacts: ARTIFACTS });
1777
+ const resolver = makeResolver();
1778
+
1779
+ const out = await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
1780
+
1781
+ expect(out).toBe('{"ok":true}');
1782
+ expect(heartbeatMock).toHaveBeenCalled();
1783
+ });
1784
+
1785
+ it('still enforces ownership before any linking happens', async () => {
1786
+ const { provider, junctions } = makeLinkProvider({
1787
+ session: makeSessionEntity({ UserID: 'someone-else' }),
1788
+ detailRows: [{ ID: 'detail-1' }],
1789
+ });
1790
+ currentProvider = provider;
1791
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true, Artifacts: ARTIFACTS });
1792
+ const resolver = makeResolver();
1793
+
1794
+ await expect(
1795
+ resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub()),
1796
+ ).rejects.toThrow(/do not own/i);
1797
+ expect(junctions).toHaveLength(0);
1798
+ });
1799
+ });
1800
+
1801
+ describe('RealtimeClientSessionResolver.StartRealtimeClientSession — prior-transcript hydration', () => {
1802
+ const okPrep = {
1803
+ Success: true,
1804
+ ClientConfig: {
1805
+ Provider: 'openai',
1806
+ Model: 'gpt-realtime',
1807
+ EphemeralToken: 'ek_abc',
1808
+ ExpiresAt: '2026-01-01T00:00:00Z',
1809
+ SessionConfig: {},
1810
+ },
1811
+ };
1812
+
1813
+ interface ChainRow {
1814
+ UserID: string;
1815
+ LastSessionID?: string | null;
1816
+ }
1817
+
1818
+ interface DetailQueryRow {
1819
+ ID: string;
1820
+ Role: string;
1821
+ Message: string | null;
1822
+ HiddenToUser: boolean;
1823
+ }
1824
+
1825
+ /**
1826
+ * Provider for the hydration path: GetEntityObject serves prior-session loads from a chain
1827
+ * MAP (Load(id) hydrates the entity from the map, false when absent), RunView routes by
1828
+ * EntityName — transcript details vs. channel-state rows.
1829
+ */
1830
+ function makeHydrationProvider(opts: {
1831
+ chain: Record<string, ChainRow>;
1832
+ detailRows?: DetailQueryRow[];
1833
+ detailQueryFails?: boolean;
1834
+ }): { provider: unknown; runView: ReturnType<typeof vi.fn> } {
1835
+ const runView = vi.fn(async (params: { EntityName: string }) => {
1836
+ if (params.EntityName === 'MJ: Conversation Details') {
1837
+ return opts.detailQueryFails
1838
+ ? { Success: false, ErrorMessage: 'db down', Results: [] }
1839
+ : { Success: true, Results: opts.detailRows ?? [] };
1840
+ }
1841
+ return { Success: true, Results: [] }; // session-channel restore — none
1842
+ });
1843
+ const provider = {
1844
+ GetEntityObject: vi.fn(async () => {
1845
+ const entity = makeSessionEntity();
1846
+ entity.Load = vi.fn(async (id: string) => {
1847
+ const row = opts.chain[id];
1848
+ if (!row) {
1849
+ return false;
1850
+ }
1851
+ entity.ID = id;
1852
+ entity.UserID = row.UserID;
1853
+ entity.LastSessionID = row.LastSessionID ?? null;
1854
+ return true;
1855
+ });
1856
+ return entity;
1857
+ }),
1858
+ RunView: runView,
1859
+ };
1860
+ return { provider, runView };
1861
+ }
1862
+
1863
+ function setupHappyStart(): void {
1864
+ hasPermissionMock.mockResolvedValue(true);
1865
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-new' }));
1866
+ prepareClientSessionMock.mockResolvedValue(okPrep);
1867
+ }
1868
+
1869
+ function prepTranscriptArg(): string | undefined {
1870
+ return (prepareClientSessionMock.mock.calls[0][0] as { PriorTranscript?: string }).PriorTranscript;
1871
+ }
1872
+
1873
+ function turn(role: 'User' | 'AI', message: string, id = 'd-1'): DetailQueryRow {
1874
+ return { ID: id, Role: role, Message: message, HiddenToUser: false };
1875
+ }
1876
+
1877
+ it('threads the prior leg\'s role-tagged transcript into PrepareClientSession.PriorTranscript', async () => {
1878
+ setupHappyStart();
1879
+ const { provider } = makeHydrationProvider({
1880
+ chain: { 'prior-1': { UserID: 'user-1' } },
1881
+ detailRows: [turn('User', 'hello there'), turn('AI', 'hi! how can I help?')],
1882
+ });
1883
+ currentProvider = provider;
1884
+ const resolver = makeResolver();
1885
+
1886
+ await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1887
+
1888
+ expect(prepTranscriptArg()).toBe('User: hello there\nAssistant: hi! how can I help?');
1889
+ });
1890
+
1891
+ it('walks the prior chain through LastSessionID and queries ALL legs\' details at once', async () => {
1892
+ setupHappyStart();
1893
+ const { provider, runView } = makeHydrationProvider({
1894
+ chain: {
1895
+ 'prior-2': { UserID: 'user-1', LastSessionID: 'prior-1' },
1896
+ 'prior-1': { UserID: 'user-1' },
1897
+ },
1898
+ detailRows: [turn('User', 'first leg'), turn('AI', 'second leg')],
1899
+ });
1900
+ currentProvider = provider;
1901
+ const resolver = makeResolver();
1902
+
1903
+ await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-2');
1904
+
1905
+ const detailCall = runView.mock.calls.find(
1906
+ (c) => (c[0] as { EntityName: string }).EntityName === 'MJ: Conversation Details',
1907
+ );
1908
+ expect(detailCall).toBeDefined();
1909
+ const filter = (detailCall![0] as { ExtraFilter: string }).ExtraFilter;
1910
+ expect(filter).toContain("'prior-2'");
1911
+ expect(filter).toContain("'prior-1'");
1912
+ expect(prepTranscriptArg()).toBe('User: first leg\nAssistant: second leg');
1913
+ });
1914
+
1915
+ it('NEVER loops on a cyclic prior chain (A→B→A)', async () => {
1916
+ setupHappyStart();
1917
+ const { provider } = makeHydrationProvider({
1918
+ chain: {
1919
+ 'prior-a': { UserID: 'user-1', LastSessionID: 'prior-b' },
1920
+ 'prior-b': { UserID: 'user-1', LastSessionID: 'prior-a' },
1921
+ },
1922
+ detailRows: [turn('User', 'looped once')],
1923
+ });
1924
+ currentProvider = provider;
1925
+ const resolver = makeResolver();
1926
+
1927
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-a');
1928
+
1929
+ expect(result.AgentSessionId).toBe('session-new');
1930
+ expect(prepTranscriptArg()).toBe('User: looped once');
1931
+ });
1932
+
1933
+ it('keeps only the NEWEST 30 turns (oldest dropped)', async () => {
1934
+ setupHappyStart();
1935
+ const rows = Array.from({ length: 35 }, (_, i) => turn('User', `turn ${i}`, `d-${i}`));
1936
+ const { provider } = makeHydrationProvider({ chain: { 'prior-1': { UserID: 'user-1' } }, detailRows: rows });
1937
+ currentProvider = provider;
1938
+ const resolver = makeResolver();
1939
+
1940
+ await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1941
+
1942
+ const lines = (prepTranscriptArg() ?? '').split('\n');
1943
+ expect(lines).toHaveLength(30);
1944
+ expect(lines[0]).toBe('User: turn 5');
1945
+ expect(lines[29]).toBe('User: turn 34');
1946
+ });
1947
+
1948
+ it('caps the transcript at ~8k chars, dropping the OLDEST lines first', async () => {
1949
+ setupHappyStart();
1950
+ // 10 turns of 1000 chars each — only the newest ~7 fit the 8000-char budget.
1951
+ const rows = Array.from({ length: 10 }, (_, i) => turn('User', `${i}`.padEnd(1000, 'x'), `d-${i}`));
1952
+ const { provider } = makeHydrationProvider({ chain: { 'prior-1': { UserID: 'user-1' } }, detailRows: rows });
1953
+ currentProvider = provider;
1954
+ const resolver = makeResolver();
1955
+
1956
+ await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1957
+
1958
+ const transcript = prepTranscriptArg() ?? '';
1959
+ expect(transcript.length).toBeLessThanOrEqual(8000);
1960
+ const lines = transcript.split('\n');
1961
+ expect(lines).toHaveLength(7);
1962
+ expect(lines[0].startsWith('User: 3')).toBe(true); // 0–2 dropped, newest 7 kept
1963
+ expect(lines[6].startsWith('User: 9')).toBe(true);
1964
+ });
1965
+
1966
+ it('skips hidden, error-role, and empty rows', async () => {
1967
+ setupHappyStart();
1968
+ const { provider } = makeHydrationProvider({
1969
+ chain: { 'prior-1': { UserID: 'user-1' } },
1970
+ detailRows: [
1971
+ { ID: 'd-1', Role: 'Error', Message: 'boom', HiddenToUser: false },
1972
+ { ID: 'd-2', Role: 'AI', Message: 'secret', HiddenToUser: true },
1973
+ { ID: 'd-3', Role: 'User', Message: ' ', HiddenToUser: false },
1974
+ { ID: 'd-4', Role: 'User', Message: null, HiddenToUser: false },
1975
+ { ID: 'd-5', Role: 'User', Message: 'visible', HiddenToUser: false },
1976
+ ],
1977
+ });
1978
+ currentProvider = provider;
1979
+ const resolver = makeResolver();
1980
+
1981
+ await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
1982
+
1983
+ expect(prepTranscriptArg()).toBe('User: visible');
1984
+ });
1985
+
1986
+ it('omits PriorTranscript when no lastSessionId is supplied', async () => {
1987
+ setupHappyStart();
1988
+ const { provider } = makeHydrationProvider({ chain: {}, detailRows: [turn('User', 'never seen')] });
1989
+ currentProvider = provider;
1990
+ const resolver = makeResolver();
1991
+
1992
+ await resolver.StartRealtimeClientSession('target-1', makeCtx());
1993
+
1994
+ expect(prepTranscriptArg()).toBeUndefined();
1995
+ });
1996
+
1997
+ it('omits PriorTranscript (no leak) when the prior session belongs to ANOTHER user', async () => {
1998
+ setupHappyStart();
1999
+ const { provider } = makeHydrationProvider({
2000
+ chain: { 'prior-1': { UserID: 'someone-else' } },
2001
+ detailRows: [turn('User', 'their words')],
2002
+ });
2003
+ currentProvider = provider;
2004
+ const resolver = makeResolver();
2005
+
2006
+ await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
2007
+
2008
+ expect(prepTranscriptArg()).toBeUndefined();
2009
+ });
2010
+
2011
+ it('stops the chain walk at the first UNOWNED deeper leg but keeps the owned legs', async () => {
2012
+ setupHappyStart();
2013
+ const { provider, runView } = makeHydrationProvider({
2014
+ chain: {
2015
+ 'prior-2': { UserID: 'user-1', LastSessionID: 'prior-1' },
2016
+ 'prior-1': { UserID: 'someone-else' },
2017
+ },
2018
+ detailRows: [turn('User', 'owned leg only')],
2019
+ });
2020
+ currentProvider = provider;
2021
+ const resolver = makeResolver();
2022
+
2023
+ await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-2');
2024
+
2025
+ const detailCall = runView.mock.calls.find(
2026
+ (c) => (c[0] as { EntityName: string }).EntityName === 'MJ: Conversation Details',
2027
+ );
2028
+ const filter = (detailCall![0] as { ExtraFilter: string }).ExtraFilter;
2029
+ expect(filter).toContain("'prior-2'");
2030
+ expect(filter).not.toContain("'prior-1'");
2031
+ expect(prepTranscriptArg()).toBe('User: owned leg only');
2032
+ });
2033
+
2034
+ it('tolerates a failed details query (start succeeds, no hydration)', async () => {
2035
+ setupHappyStart();
2036
+ const { provider } = makeHydrationProvider({
2037
+ chain: { 'prior-1': { UserID: 'user-1' } },
2038
+ detailQueryFails: true,
2039
+ });
2040
+ currentProvider = provider;
2041
+ const resolver = makeResolver();
2042
+
2043
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
2044
+
2045
+ expect(result.AgentSessionId).toBe('session-new');
2046
+ expect(prepTranscriptArg()).toBeUndefined();
2047
+ });
2048
+
2049
+ it('tolerates a thrown hydration failure (start succeeds, no hydration)', async () => {
2050
+ setupHappyStart();
2051
+ currentProvider = {
2052
+ GetEntityObject: vi.fn(async () => {
2053
+ throw new Error('provider exploded');
2054
+ }),
2055
+ RunView: vi.fn(async () => ({ Success: true, Results: [] })),
2056
+ };
2057
+ const resolver = makeResolver();
2058
+
2059
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx(), undefined, 'prior-1');
2060
+
2061
+ expect(result.AgentSessionId).toBe('session-new');
2062
+ expect(prepTranscriptArg()).toBeUndefined();
2063
+ });
2064
+ });
2065
+
2066
+ describe('RealtimeClientSessionResolver.CancelRealtimeSessionTool', () => {
2067
+ it('enforces ownership — rejects (no cancel) when the caller does not own the session', async () => {
2068
+ currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'someone-else' }));
2069
+ const resolver = makeResolver();
2070
+
2071
+ await expect(
2072
+ resolver.CancelRealtimeSessionTool('session-1', makeCtx(), 'call-1'),
2073
+ ).rejects.toThrow(/do not own/i);
2074
+ expect(cancelInFlightMock).not.toHaveBeenCalled();
2075
+ });
2076
+
2077
+ it('throws before any session load when unauthenticated', async () => {
2078
+ const provider = makeProvider(() => makeSessionEntity());
2079
+ currentProvider = provider;
2080
+ const resolver = new RealtimeClientSessionResolver();
2081
+ (resolver as unknown as { GetUserFromPayload: () => unknown }).GetUserFromPayload = () => undefined;
2082
+
2083
+ await expect(resolver.CancelRealtimeSessionTool('session-1', makeCtx())).rejects.toThrow(/not authenticated/i);
2084
+ expect((provider as { GetEntityObject: ReturnType<typeof vi.fn> }).GetEntityObject).not.toHaveBeenCalled();
2085
+ expect(cancelInFlightMock).not.toHaveBeenCalled();
2086
+ });
2087
+
2088
+ it('cancels ONE call when callId is supplied and returns the service count in the structured result', async () => {
2089
+ currentProvider = makeProvider(() => makeSessionEntity());
2090
+ cancelInFlightMock.mockReturnValue(1);
2091
+ const resolver = makeResolver();
2092
+
2093
+ const result = await resolver.CancelRealtimeSessionTool('session-1', makeCtx(), 'call-7');
2094
+
2095
+ expect(result).toEqual({ AbortedCount: 1, Success: true });
2096
+ expect(cancelInFlightMock).toHaveBeenCalledWith('session-1', 'call-7');
2097
+ });
2098
+
2099
+ it('cancels ALL in-flight delegations when callId is omitted', async () => {
2100
+ currentProvider = makeProvider(() => makeSessionEntity());
2101
+ cancelInFlightMock.mockReturnValue(3);
2102
+ const resolver = makeResolver();
2103
+
2104
+ const result = await resolver.CancelRealtimeSessionTool('session-1', makeCtx());
2105
+
2106
+ expect(result).toEqual({ AbortedCount: 3, Success: true });
2107
+ expect(cancelInFlightMock).toHaveBeenCalledWith('session-1', undefined);
2108
+ });
2109
+
2110
+ it('is tolerant — Success with AbortedCount 0 when nothing was in flight (the work may have finished already)', async () => {
2111
+ currentProvider = makeProvider(() => makeSessionEntity());
2112
+ cancelInFlightMock.mockReturnValue(0);
2113
+ const resolver = makeResolver();
2114
+
2115
+ await expect(resolver.CancelRealtimeSessionTool('session-1', makeCtx(), 'call-gone')).resolves.toEqual({
2116
+ AbortedCount: 0,
2117
+ Success: true,
2118
+ });
2119
+ });
2120
+
2121
+ it('accepts a CLOSED session (a cancel can legitimately race teardown)', async () => {
2122
+ currentProvider = makeProvider(() => makeSessionEntity({ Status: 'Closed' }));
2123
+ cancelInFlightMock.mockReturnValue(1);
2124
+ const resolver = makeResolver();
2125
+
2126
+ await expect(resolver.CancelRealtimeSessionTool('session-1', makeCtx())).resolves.toEqual({
2127
+ AbortedCount: 1,
2128
+ Success: true,
2129
+ });
2130
+ });
2131
+
2132
+ it('returns a structured failure (never throws) when the registry abort throws unexpectedly', async () => {
2133
+ currentProvider = makeProvider(() => makeSessionEntity());
2134
+ cancelInFlightMock.mockImplementation(() => {
2135
+ throw new Error('registry exploded');
2136
+ });
2137
+ const resolver = makeResolver();
2138
+
2139
+ const result = await resolver.CancelRealtimeSessionTool('session-1', makeCtx(), 'call-1');
2140
+
2141
+ expect(result.Success).toBe(false);
2142
+ expect(result.AbortedCount).toBe(0);
2143
+ expect(result.ErrorMessage).toContain('registry exploded');
2144
+ });
2145
+ });
2146
+
2147
+ describe('RealtimeClientSessionResolver.RelayRealtimeUsage', () => {
2148
+ /** Provider routing the session and the co-agent prompt run by entity name. */
2149
+ function makeUsageProvider(opts: {
2150
+ session?: FakeSession;
2151
+ promptRun?: FakeSession;
2152
+ }): { provider: unknown; promptRun: FakeSession } {
2153
+ const session =
2154
+ opts.session ??
2155
+ makeSessionEntity({ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }) });
2156
+ const promptRun = opts.promptRun ?? makeSessionEntity({ ID: 'prompt-run-1', TokensPrompt: null, TokensCompletion: null, TokensUsed: null });
2157
+ const provider = {
2158
+ GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: AI Prompt Runs' ? promptRun : session)),
2159
+ };
2160
+ return { provider, promptRun };
2161
+ }
2162
+
2163
+ it('enforces ownership — rejects when the caller does not own the session', async () => {
2164
+ const { provider, promptRun } = makeUsageProvider({ session: makeSessionEntity({ UserID: 'someone-else' }) });
2165
+ currentProvider = provider;
2166
+ const resolver = makeResolver();
2167
+
2168
+ await expect(
2169
+ resolver.RelayRealtimeUsage('session-1', 100, 25, makeCtx()),
2170
+ ).rejects.toThrow(/do not own/i);
2171
+ expect(promptRun.Save).not.toHaveBeenCalled();
2172
+ });
2173
+
2174
+ it('ACCUMULATES the relayed deltas onto TokensPrompt/TokensCompletion and recomputes TokensUsed', async () => {
2175
+ const { provider, promptRun } = makeUsageProvider({
2176
+ promptRun: makeSessionEntity({ ID: 'prompt-run-1', TokensPrompt: 100, TokensCompletion: 40, TokensUsed: 140 }),
2177
+ });
2178
+ currentProvider = provider;
2179
+ const resolver = makeResolver();
2180
+
2181
+ const ok = await resolver.RelayRealtimeUsage('session-1', 30, 5, makeCtx());
2182
+
2183
+ expect(ok).toBe(true);
2184
+ expect(promptRun.Load).toHaveBeenCalledWith('prompt-run-1');
2185
+ expect(promptRun.TokensPrompt).toBe(130);
2186
+ expect(promptRun.TokensCompletion).toBe(45);
2187
+ expect(promptRun.TokensUsed).toBe(175);
2188
+ expect(promptRun.Save).toHaveBeenCalled();
2189
+ });
2190
+
2191
+ it('treats null token columns as 0 on first accumulation', async () => {
2192
+ const { provider, promptRun } = makeUsageProvider({});
2193
+ currentProvider = provider;
2194
+ const resolver = makeResolver();
2195
+
2196
+ const ok = await resolver.RelayRealtimeUsage('session-1', 120, 45, makeCtx());
2197
+
2198
+ expect(ok).toBe(true);
2199
+ expect(promptRun.TokensPrompt).toBe(120);
2200
+ expect(promptRun.TokensCompletion).toBe(45);
2201
+ expect(promptRun.TokensUsed).toBe(165);
2202
+ });
2203
+
2204
+ it('is a successful no-op (no prompt-run load) when both deltas are zero/negative/non-finite', async () => {
2205
+ const { provider, promptRun } = makeUsageProvider({});
2206
+ currentProvider = provider;
2207
+ const resolver = makeResolver();
2208
+
2209
+ await expect(resolver.RelayRealtimeUsage('session-1', 0, 0, makeCtx())).resolves.toBe(true);
2210
+ await expect(resolver.RelayRealtimeUsage('session-1', -5, -1, makeCtx())).resolves.toBe(true);
2211
+ await expect(resolver.RelayRealtimeUsage('session-1', Number.NaN, 0, makeCtx())).resolves.toBe(true);
2212
+ expect(promptRun.Load).not.toHaveBeenCalled();
2213
+ expect(promptRun.Save).not.toHaveBeenCalled();
2214
+ });
2215
+
2216
+ it('clamps a negative delta to 0 while accumulating the positive one', async () => {
2217
+ const { provider, promptRun } = makeUsageProvider({
2218
+ promptRun: makeSessionEntity({ ID: 'prompt-run-1', TokensPrompt: 10, TokensCompletion: 10, TokensUsed: 20 }),
2219
+ });
2220
+ currentProvider = provider;
2221
+ const resolver = makeResolver();
2222
+
2223
+ const ok = await resolver.RelayRealtimeUsage('session-1', -50, 7, makeCtx());
2224
+
2225
+ expect(ok).toBe(true);
2226
+ expect(promptRun.TokensPrompt).toBe(10);
2227
+ expect(promptRun.TokensCompletion).toBe(17);
2228
+ expect(promptRun.TokensUsed).toBe(27);
2229
+ });
2230
+
2231
+ it('returns false (tolerant) when the session config carries no promptRunID', async () => {
2232
+ const { provider, promptRun } = makeUsageProvider({
2233
+ session: makeSessionEntity({ Config_: JSON.stringify({ targetAgentID: 'target-1' }) }),
2234
+ });
2235
+ currentProvider = provider;
2236
+ const resolver = makeResolver();
2237
+
2238
+ await expect(resolver.RelayRealtimeUsage('session-1', 10, 5, makeCtx())).resolves.toBe(false);
2239
+ expect(promptRun.Save).not.toHaveBeenCalled();
2240
+ });
2241
+
2242
+ it('returns false (tolerant, no throw) when the session config is missing/malformed', async () => {
2243
+ const { provider } = makeUsageProvider({ session: makeSessionEntity({ Config_: '{broken json' }) });
2244
+ currentProvider = provider;
2245
+ const resolver = makeResolver();
2246
+
2247
+ await expect(resolver.RelayRealtimeUsage('session-1', 10, 5, makeCtx())).resolves.toBe(false);
2248
+ });
2249
+
2250
+ it('returns false when the co-agent prompt run cannot be loaded', async () => {
2251
+ const { provider, promptRun } = makeUsageProvider({
2252
+ promptRun: makeSessionEntity({ ID: 'prompt-run-1', Load: vi.fn(async () => false) }),
2253
+ });
2254
+ currentProvider = provider;
2255
+ const resolver = makeResolver();
2256
+
2257
+ await expect(resolver.RelayRealtimeUsage('session-1', 10, 5, makeCtx())).resolves.toBe(false);
2258
+ expect(promptRun.Save).not.toHaveBeenCalled();
2259
+ });
2260
+
2261
+ it('returns false (logged) when the prompt-run save fails', async () => {
2262
+ const { provider } = makeUsageProvider({
2263
+ promptRun: makeSessionEntity({
2264
+ ID: 'prompt-run-1',
2265
+ TokensPrompt: null,
2266
+ TokensCompletion: null,
2267
+ Save: vi.fn(async () => false),
2268
+ LatestResult: { CompleteMessage: 'db down' },
2269
+ }),
2270
+ });
2271
+ currentProvider = provider;
2272
+ const resolver = makeResolver();
2273
+
2274
+ await expect(resolver.RelayRealtimeUsage('session-1', 10, 5, makeCtx())).resolves.toBe(false);
2275
+ });
2276
+
2277
+ it('accepts a CLOSED session (the final teardown flush lands after CloseAgentSession)', async () => {
2278
+ const { provider, promptRun } = makeUsageProvider({
2279
+ session: makeSessionEntity({
2280
+ Status: 'Closed',
2281
+ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
2282
+ }),
2283
+ });
2284
+ currentProvider = provider;
2285
+ const resolver = makeResolver();
2286
+
2287
+ const ok = await resolver.RelayRealtimeUsage('session-1', 9, 3, makeCtx());
2288
+
2289
+ expect(ok).toBe(true);
2290
+ expect(promptRun.TokensPrompt).toBe(9);
2291
+ expect(promptRun.TokensCompletion).toBe(3);
2292
+ expect(promptRun.TokensUsed).toBe(12);
2293
+ });
2294
+ });
2295
+
2296
+ describe('RealtimeClientSessionResolver.StartRealtimeClientSession — pairing constraints (MJ: AI Agent Co Agents)', () => {
2297
+ const okPrep = {
2298
+ Success: true,
2299
+ ClientConfig: {
2300
+ Provider: 'openai',
2301
+ Model: 'gpt-realtime',
2302
+ EphemeralToken: 'ek_abc',
2303
+ ExpiresAt: '2026-01-01T00:00:00Z',
2304
+ SessionConfig: {},
2305
+ },
2306
+ };
2307
+
2308
+ /**
2309
+ * Seeds the mocked engine cache with pairing rows for the resolved co-agent ('co-agent-1')
2310
+ * — the resolver filters the cached `AgentCoAgents` by `CoAgentID`, relationship Type
2311
+ * ('CoAgent'), Status ('Active') and agent-target presence itself, so the rows carry the
2312
+ * full new-entity shape (plus stray rows — another co-agent's, a Disabled row, a reserved
2313
+ * Peer-type row, and a type-level row — to prove every filter). `failPairingQuery` makes
2314
+ * the engine load reject, exercising the degrade-to-universal path.
2315
+ */
2316
+ function makePairingProvider(
2317
+ pairingRows: Array<{ TargetAgentID: string; IsDefault: boolean; Sequence: number }>,
2318
+ opts: { failPairingQuery?: boolean } = {},
2319
+ ): { provider: unknown } {
2320
+ if (opts.failPairingQuery) {
2321
+ engineConfigMock.mockRejectedValue(new Error('db down'));
2322
+ } else {
2323
+ engineCoAgentsMock.mockReturnValue([
2324
+ ...pairingRows.map((r, i) => ({ ID: `pair-${i}`, CoAgentID: 'co-agent-1', Type: 'CoAgent' as const, Status: 'Active' as const, ...r })),
2325
+ // Another co-agent's pairing — must never constrain THIS co-agent.
2326
+ { ID: 'pair-other', CoAgentID: 'co-agent-other', TargetAgentID: 'target-elsewhere', Type: 'CoAgent' as const, Status: 'Active' as const, IsDefault: true, Sequence: 0 },
2327
+ // Disabled row for THIS co-agent — must be ignored by resolution.
2328
+ { ID: 'pair-disabled', CoAgentID: 'co-agent-1', TargetAgentID: 'target-disabled', Type: 'CoAgent' as const, Status: 'Disabled' as const, IsDefault: false, Sequence: 99 },
2329
+ // Reserved relationship type — must be ignored until its feature ships.
2330
+ { ID: 'pair-peer', CoAgentID: 'co-agent-1', TargetAgentID: 'target-peer-only', Type: 'Peer' as const, Status: 'Active' as const, IsDefault: false, Sequence: 0 },
2331
+ // Type-level row — expresses the type default, never a target restriction.
2332
+ { ID: 'pair-type', CoAgentID: 'co-agent-1', TargetAgentID: null, TargetAgentTypeID: 'type-loop', Type: 'CoAgent' as const, Status: 'Active' as const, IsDefault: false, Sequence: 0 },
2333
+ ]);
2334
+ }
2335
+ return { provider: { GetEntityObject: vi.fn(async () => makeSessionEntity()), RunView: vi.fn(async () => ({ Success: true, Results: [] })) } };
2336
+ }
2337
+
2338
+ function setupHappyStart(): void {
2339
+ hasPermissionMock.mockResolvedValue(true);
2340
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-pair' }));
2341
+ prepareClientSessionMock.mockResolvedValue(okPrep);
2342
+ }
2343
+
2344
+ it('zero pairing rows (universal) — behaves exactly as today with a runtime target', async () => {
2345
+ setupHappyStart();
2346
+ const { provider } = makePairingProvider([]);
2347
+ currentProvider = provider;
2348
+ const resolver = makeResolver();
2349
+
2350
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx());
2351
+
2352
+ expect(result.AgentSessionId).toBe('session-pair');
2353
+ expect(hasPermissionMock).toHaveBeenCalledWith('target-1', USER, 'run');
2354
+ const createArg = createSessionMock.mock.calls[0][0] as { config: string };
2355
+ expect(JSON.parse(createArg.config).targetAgentID).toBe('target-1');
2356
+ });
2357
+
2358
+ it('zero pairing rows + NO runtime target — clear error, no session created', async () => {
2359
+ setupHappyStart();
2360
+ const { provider } = makePairingProvider([]);
2361
+ currentProvider = provider;
2362
+ const resolver = makeResolver();
2363
+
2364
+ await expect(
2365
+ resolver.StartRealtimeClientSession(undefined, makeCtx()),
2366
+ ).rejects.toThrow(/targetAgentId is required/i);
2367
+ expect(createSessionMock).not.toHaveBeenCalled();
2368
+ expect(hasPermissionMock).not.toHaveBeenCalled();
2369
+ });
2370
+
2371
+ it('pairing rows RESTRICT: a runtime target inside the list is accepted', async () => {
2372
+ setupHappyStart();
2373
+ const { provider } = makePairingProvider([
2374
+ { TargetAgentID: 'target-a', IsDefault: false, Sequence: 0 },
2375
+ { TargetAgentID: 'target-b', IsDefault: true, Sequence: 1 },
2376
+ ]);
2377
+ currentProvider = provider;
2378
+ const resolver = makeResolver();
2379
+
2380
+ await resolver.StartRealtimeClientSession('target-a', makeCtx());
2381
+
2382
+ const createArg = createSessionMock.mock.calls[0][0] as { config: string };
2383
+ expect(JSON.parse(createArg.config).targetAgentID).toBe('target-a');
2384
+ });
2385
+
2386
+ it('pairing rows RESTRICT: a runtime target OUTSIDE the list is a structured error (no session)', async () => {
2387
+ setupHappyStart();
2388
+ const { provider } = makePairingProvider([
2389
+ { TargetAgentID: 'target-a', IsDefault: true, Sequence: 0 },
2390
+ ]);
2391
+ currentProvider = provider;
2392
+ const resolver = makeResolver();
2393
+
2394
+ await expect(
2395
+ resolver.StartRealtimeClientSession('target-rogue', makeCtx()),
2396
+ ).rejects.toThrow(/not in it/i);
2397
+ expect(createSessionMock).not.toHaveBeenCalled();
2398
+ });
2399
+
2400
+ it('matches paired targets UUID-case-insensitively and uses the canonical row casing', async () => {
2401
+ setupHappyStart();
2402
+ const { provider } = makePairingProvider([
2403
+ { TargetAgentID: 'ABCDEF00-0000-0000-0000-000000000001', IsDefault: false, Sequence: 0 },
2404
+ ]);
2405
+ currentProvider = provider;
2406
+ const resolver = makeResolver();
2407
+
2408
+ await resolver.StartRealtimeClientSession('abcdef00-0000-0000-0000-000000000001', makeCtx());
2409
+
2410
+ const createArg = createSessionMock.mock.calls[0][0] as { config: string };
2411
+ // Canonical casing from the pairing row, not the caller's.
2412
+ expect(JSON.parse(createArg.config).targetAgentID).toBe('ABCDEF00-0000-0000-0000-000000000001');
2413
+ });
2414
+
2415
+ it('no runtime target + IsDefault row — the default pairing stands in (and CanRun gates IT)', async () => {
2416
+ setupHappyStart();
2417
+ const { provider } = makePairingProvider([
2418
+ { TargetAgentID: 'target-a', IsDefault: false, Sequence: 0 },
2419
+ { TargetAgentID: 'target-default', IsDefault: true, Sequence: 1 },
2420
+ ]);
2421
+ currentProvider = provider;
2422
+ const resolver = makeResolver();
2423
+
2424
+ await resolver.StartRealtimeClientSession(undefined, makeCtx());
2425
+
2426
+ expect(hasPermissionMock).toHaveBeenCalledWith('target-default', USER, 'run');
2427
+ const createArg = createSessionMock.mock.calls[0][0] as { config: string };
2428
+ expect(JSON.parse(createArg.config).targetAgentID).toBe('target-default');
2429
+ });
2430
+
2431
+ it('no runtime target + NO IsDefault row — clear error asking for an explicit target', async () => {
2432
+ setupHappyStart();
2433
+ const { provider } = makePairingProvider([
2434
+ { TargetAgentID: 'target-a', IsDefault: false, Sequence: 0 },
2435
+ ]);
2436
+ currentProvider = provider;
2437
+ const resolver = makeResolver();
2438
+
2439
+ await expect(
2440
+ resolver.StartRealtimeClientSession(undefined, makeCtx()),
2441
+ ).rejects.toThrow(/no pairing is marked IsDefault/i);
2442
+ expect(createSessionMock).not.toHaveBeenCalled();
2443
+ });
2444
+
2445
+ it('a failed pairing query degrades to universal (logged, CanRun still gates) — never breaks starts', async () => {
2446
+ setupHappyStart();
2447
+ const { provider } = makePairingProvider([], { failPairingQuery: true });
2448
+ currentProvider = provider;
2449
+ const resolver = makeResolver();
2450
+
2451
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx());
2452
+ expect(result.AgentSessionId).toBe('session-pair');
2453
+ expect(hasPermissionMock).toHaveBeenCalledWith('target-1', USER, 'run');
2454
+ });
2455
+
2456
+ it('CanRun is enforced on the pairing-resolved target (denial throws, no session)', async () => {
2457
+ setupHappyStart();
2458
+ hasPermissionMock.mockResolvedValue(false);
2459
+ const { provider } = makePairingProvider([
2460
+ { TargetAgentID: 'target-default', IsDefault: true, Sequence: 0 },
2461
+ ]);
2462
+ currentProvider = provider;
2463
+ const resolver = makeResolver();
2464
+
2465
+ await expect(resolver.StartRealtimeClientSession(undefined, makeCtx())).rejects.toThrow(/not authorized/i);
2466
+ expect(createSessionMock).not.toHaveBeenCalled();
2467
+ });
2468
+ });
2469
+
2470
+ describe('RealtimeClientSessionResolver.StartRealtimeClientSession — runtime-override authorization gate', () => {
2471
+ const okPrep = {
2472
+ Success: true,
2473
+ ClientConfig: {
2474
+ Provider: 'openai',
2475
+ Model: 'gpt-realtime',
2476
+ EphemeralToken: 'ek_abc',
2477
+ ExpiresAt: '2026-01-01T00:00:00Z',
2478
+ SessionConfig: {},
2479
+ },
2480
+ };
2481
+ const OVERRIDES = JSON.stringify({ realtime: { narration: { paceMs: 3000 } } });
2482
+
2483
+ /** Sets up a happy start whose provider carries the given Authorizations rows. */
2484
+ function setupWithAuthorizations(authorizations: unknown[] | undefined): void {
2485
+ hasPermissionMock.mockResolvedValue(true);
2486
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-ovr' }));
2487
+ prepareClientSessionMock.mockResolvedValue(okPrep);
2488
+ const provider = makeProvider(() => makeSessionEntity()) as { Authorizations?: unknown };
2489
+ provider.Authorizations = authorizations;
2490
+ currentProvider = provider;
2491
+ }
2492
+
2493
+ /** Positional helper: start with configOverridesJson (the 8th arg) and optional model. */
2494
+ async function start(opts: { overrides?: string; model?: string } = {}): Promise<unknown> {
2495
+ const resolver = makeResolver();
2496
+ return resolver.StartRealtimeClientSession(
2497
+ 'target-1', makeCtx(), undefined, undefined, opts.model, undefined, undefined, opts.overrides,
2498
+ );
2499
+ }
2500
+
2501
+ it('REJECTS configOverridesJson without the authorization (structured, names the authorization, no session)', async () => {
2502
+ setupWithAuthorizations([DENIED_ADVANCED_CONTROLS_AUTH]);
2503
+
2504
+ await expect(start({ overrides: OVERRIDES })).rejects.toThrow(/Advanced Session Controls/);
2505
+ expect(createSessionMock).not.toHaveBeenCalled();
2506
+ expect(prepareClientSessionMock).not.toHaveBeenCalled();
2507
+ });
2508
+
2509
+ it('REJECTS configOverridesJson when the authorization row is absent from metadata (fail closed)', async () => {
2510
+ setupWithAuthorizations([]); // un-synced seed
2511
+ await expect(start({ overrides: OVERRIDES })).rejects.toThrow(/Advanced Session Controls/);
2512
+ });
2513
+
2514
+ it('threads configOverridesJson to the prepare service for an AUTHORIZED caller', async () => {
2515
+ setupWithAuthorizations([GRANTED_ADVANCED_CONTROLS_AUTH]);
2516
+
2517
+ await start({ overrides: OVERRIDES });
2518
+
2519
+ const prepArg = prepareClientSessionMock.mock.calls[0][0] as { ConfigOverridesJson?: string };
2520
+ expect(prepArg.ConfigOverridesJson).toBe(OVERRIDES);
2521
+ });
2522
+
2523
+ it('REJECTS malformed configOverridesJson (not a JSON object) even for authorized callers', async () => {
2524
+ setupWithAuthorizations([GRANTED_ADVANCED_CONTROLS_AUTH]);
2525
+ await expect(start({ overrides: '[1,2,3]' })).rejects.toThrow(/expected a JSON object/i);
2526
+ await expect(start({ overrides: '{broken' })).rejects.toThrow(/expected a JSON object/i);
2527
+ expect(createSessionMock).not.toHaveBeenCalled();
2528
+ });
2529
+
2530
+ it('REJECTS an explicit preferredModelId for an unauthorized caller (deviating model)', async () => {
2531
+ setupWithAuthorizations([DENIED_ADVANCED_CONTROLS_AUTH]);
2532
+ await expect(start({ model: 'model-77' })).rejects.toThrow(/Advanced Session Controls/);
2533
+ expect(createSessionMock).not.toHaveBeenCalled();
2534
+ });
2535
+
2536
+ it('ALLOWS an explicit preferredModelId equal to the co-agent metadata preference (no deviation), unauthorized', async () => {
2537
+ setupWithAuthorizations([DENIED_ADVANCED_CONTROLS_AUTH]);
2538
+ // The co-agent's TypeConfiguration prefers model-77 — requesting exactly it is not a deviation.
2539
+ agentsMock.mockReturnValue([
2540
+ {
2541
+ ID: 'co-agent-1',
2542
+ Name: 'Realtime Co-Agent',
2543
+ TypeConfiguration: JSON.stringify({ realtime: { modelPreference: 'model-77' } }),
2544
+ } as never,
2545
+ ]);
2546
+
2547
+ await start({ model: 'model-77' });
2548
+
2549
+ const prepArg = prepareClientSessionMock.mock.calls[0][0] as { PreferredModelID?: string };
2550
+ expect(prepArg.PreferredModelID).toBe('model-77');
2551
+ });
2552
+
2553
+ it('REJECTS a deviating preferredModelId for an AUTHORIZED caller when allowUserModelOverride=false', async () => {
2554
+ setupWithAuthorizations([GRANTED_ADVANCED_CONTROLS_AUTH]);
2555
+ agentsMock.mockReturnValue([
2556
+ {
2557
+ ID: 'co-agent-1',
2558
+ Name: 'Realtime Co-Agent',
2559
+ TypeConfiguration: JSON.stringify({
2560
+ realtime: { modelPreference: 'model-77', allowUserModelOverride: false },
2561
+ }),
2562
+ } as never,
2563
+ ]);
2564
+
2565
+ await expect(start({ model: 'model-other' })).rejects.toThrow(/allowUserModelOverride/);
2566
+ expect(createSessionMock).not.toHaveBeenCalled();
2567
+ });
2568
+
2569
+ it('ALLOWS a deviating preferredModelId for an AUTHORIZED caller when the policy permits', async () => {
2570
+ setupWithAuthorizations([GRANTED_ADVANCED_CONTROLS_AUTH]);
2571
+ await start({ model: 'model-77' });
2572
+ const prepArg = prepareClientSessionMock.mock.calls[0][0] as { PreferredModelID?: string };
2573
+ expect(prepArg.PreferredModelID).toBe('model-77');
2574
+ });
2575
+
2576
+ it('a plain start (no overrides, no model) never consults the authorization at all', async () => {
2577
+ setupWithAuthorizations(undefined); // provider has no Authorizations surface
2578
+ const result = (await start()) as { AgentSessionId: string };
2579
+ expect(result.AgentSessionId).toBe('session-ovr');
2580
+ });
2581
+
2582
+ it('surfaces NarrationPaceMs and EffectiveConfigJson from the prepare result', async () => {
2583
+ hasPermissionMock.mockResolvedValue(true);
2584
+ currentProvider = makeProvider(() => makeSessionEntity());
2585
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-cfg' }));
2586
+ prepareClientSessionMock.mockResolvedValue({
2587
+ ...okPrep,
2588
+ NarrationPaceMs: 4500,
2589
+ EffectiveConfig: { realtime: { narration: { paceMs: 4500 } } },
2590
+ });
2591
+ const resolver = makeResolver();
2592
+
2593
+ const result = await resolver.StartRealtimeClientSession('target-1', makeCtx());
2594
+
2595
+ expect(result.NarrationPaceMs).toBe(4500);
2596
+ expect(JSON.parse(result.EffectiveConfigJson as string)).toEqual({ realtime: { narration: { paceMs: 4500 } } });
2597
+ });
2598
+
2599
+ it('omits NarrationPaceMs / EffectiveConfigJson when the prepare result carries none (back-compat)', async () => {
2600
+ setupWithAuthorizations(undefined);
2601
+ const result = (await start()) as { NarrationPaceMs?: number; EffectiveConfigJson?: string };
2602
+ expect(result.NarrationPaceMs).toBeUndefined();
2603
+ expect(result.EffectiveConfigJson).toBeUndefined();
2604
+ });
2605
+ });