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

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 (93) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/generated/graphql-schemas/__mj.d.ts +1010 -0
  3. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  4. package/dist/generated/graphql-schemas/__mj.js +5178 -3
  5. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  6. package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
  7. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  8. package/dist/generic/FireAndForgetHeartbeat.js +16 -2
  9. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  10. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
  11. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
  12. package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
  13. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
  14. package/dist/generic/PushStatusResolver.d.ts +38 -0
  15. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  16. package/dist/generic/PushStatusResolver.js +84 -1
  17. package/dist/generic/PushStatusResolver.js.map +1 -1
  18. package/dist/generic/ResolverBase.d.ts +13 -1
  19. package/dist/generic/ResolverBase.d.ts.map +1 -1
  20. package/dist/generic/ResolverBase.js +49 -47
  21. package/dist/generic/ResolverBase.js.map +1 -1
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +90 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
  27. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
  28. package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
  29. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
  30. package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
  31. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
  32. package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
  33. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
  34. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
  35. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
  36. package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
  37. package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
  38. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  39. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
  40. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  41. package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
  42. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  43. package/dist/resolvers/MergeRecordsResolver.js +16 -2
  44. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  45. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
  46. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
  48. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  49. package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
  50. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RunAIAgentResolver.js +23 -153
  52. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  53. package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
  54. package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
  55. package/dist/resolvers/RunDecisionResolver.js +396 -0
  56. package/dist/resolvers/RunDecisionResolver.js.map +1 -0
  57. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  58. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  59. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  60. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  61. package/package.json +94 -92
  62. package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
  63. package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
  64. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  65. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
  66. package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
  67. package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
  68. package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
  69. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
  70. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
  71. package/src/__tests__/RunDecisionResolver.test.ts +699 -0
  72. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
  73. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  74. package/src/__tests__/conversationRunEvents.test.ts +399 -0
  75. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
  76. package/src/__tests__/pushStatusFanOut.test.ts +206 -0
  77. package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
  78. package/src/generated/graphql-schemas/__mj.ts +3637 -4
  79. package/src/generic/FireAndForgetHeartbeat.ts +16 -2
  80. package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
  81. package/src/generic/PushStatusResolver.ts +97 -1
  82. package/src/generic/ResolverBase.ts +49 -49
  83. package/src/index.ts +102 -2
  84. package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
  85. package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
  86. package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
  87. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
  88. package/src/resolvers/MergeRecordsResolver.ts +14 -2
  89. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  90. package/src/resolvers/RunAIAgentResolver.ts +28 -174
  91. package/src/resolvers/RunDecisionResolver.ts +435 -0
  92. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  93. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Conversation details left behind by a finished run must be closed (MJ #4222).
3
+ *
4
+ * `AgentRunner` closes the detail as the last step of a run, so a process that dies mid-run never
5
+ * gets there. The agent-run watchdog repairs the RUN but nothing repaired the detail — and the
6
+ * detail is the row the chat renders from. Observed directly: run B3E67FE3 force-failed by the
7
+ * watchdog while its detail stayed `In-Progress`, spinning for anyone who opened it.
8
+ *
9
+ * The properties that keep this safe to run unattended are pinned here: it never closes a message
10
+ * whose work is still going, and it never races the normal completion path.
11
+ */
12
+ import 'reflect-metadata';
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
14
+ import type { RunViewParams, UserInfo } from '@memberjunction/core';
15
+
16
+ const { mockRunView } = vi.hoisted(() => ({ mockRunView: vi.fn() }));
17
+
18
+ const { mockGetEntityObject, writableStore } = vi.hoisted(() => ({
19
+ mockGetEntityObject: vi.fn(),
20
+ writableStore: { last: null as Record<string, unknown> | null },
21
+ }));
22
+
23
+ vi.mock('@memberjunction/global', () => ({
24
+ UUIDsEqual: (a: string, b: string) => String(a).toLowerCase() === String(b).toLowerCase(),
25
+ }));
26
+
27
+ vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
28
+ UserCache: { Instance: { Users: [{ ID: 'OWNER-1', Email: 'owner@example.com' }] } },
29
+ }));
30
+
31
+ vi.mock('@memberjunction/core', () => {
32
+ class MockRunView {
33
+ public static FromMetadataProvider(): MockRunView {
34
+ return new MockRunView();
35
+ }
36
+ public async RunView(...args: unknown[]): Promise<unknown> {
37
+ return mockRunView(...args);
38
+ }
39
+ }
40
+ return { LogError: vi.fn(), LogStatus: vi.fn(), RunView: MockRunView };
41
+ });
42
+
43
+ import { ReconcileOrphanedConversationDetails, ORPHAN_DETAIL_GRACE_MS } from '../generic/OrphanedConversationDetailReconciler.js';
44
+
45
+ const USER = { ID: 'U1' } as unknown as UserInfo;
46
+ const CONVERSATION_ID = 'C1';
47
+
48
+ /**
49
+ * Provider stub: hands back the entity the reconciler actually writes through, and quotes names the
50
+ * way the SQL Server provider does.
51
+ */
52
+ const PROVIDER = {
53
+ GetEntityObject: mockGetEntityObject,
54
+ EntityByName: (name: string) =>
55
+ name === 'MJ: AI Agent Runs' ? { SchemaName: '__mj', BaseView: 'vwAIAgentRuns' } : undefined,
56
+ QuoteIdentifier: (name: string) => `[${name}]`,
57
+ QuoteSchemaAndView: (schema: string, view: string) => `[${schema}].[${view}]`,
58
+ } as never;
59
+ const DETAIL_ID = 'D1';
60
+
61
+ function detail(over: Record<string, unknown> = {}) {
62
+ return { ID: DETAIL_ID, ConversationID: CONVERSATION_ID, Status: 'In-Progress', Message: '', ...over };
63
+ }
64
+
65
+ /**
66
+ * The record the reconciler loads and saves. Separate from the RunView projection on purpose: the
67
+ * reconciler deliberately does NOT write the entity RunView returned, because that one carries the
68
+ * system user and the conversation-detail permission gate refuses a non-owner.
69
+ */
70
+ function writable(saveResult = true, loadedStatus = 'In-Progress') {
71
+ const w = {
72
+ ID: DETAIL_ID,
73
+ Status: loadedStatus,
74
+ Message: '',
75
+ Load: vi.fn(async () => true),
76
+ Save: vi.fn(async () => saveResult),
77
+ LatestResult: { CompleteMessage: 'denied' },
78
+ };
79
+ writableStore.last = w;
80
+ return w;
81
+ }
82
+
83
+ function script(details: unknown[], runs: unknown[], ownerId: string | null = 'OWNER-1', loadedStatus = 'In-Progress') {
84
+ mockRunView.mockImplementation(async (...args: unknown[]) => {
85
+ const params = args[0] as RunViewParams | undefined;
86
+ if (!params) throw new Error('RunView called with no params');
87
+ if (params.EntityName === 'MJ: Conversation Details') return { Success: true, Results: details };
88
+ if (params.EntityName === 'MJ: AI Agent Runs') return { Success: true, Results: runs };
89
+ if (params.EntityName === 'MJ: Conversations') {
90
+ return { Success: true, Results: ownerId ? [{ ID: CONVERSATION_ID, UserID: ownerId }] : [] };
91
+ }
92
+ throw new Error(`unexpected entity ${params.EntityName}`);
93
+ });
94
+ mockGetEntityObject.mockImplementation(async () => writable(true, loadedStatus));
95
+ }
96
+
97
+ const longAgo = () => new Date(Date.now() - ORPHAN_DETAIL_GRACE_MS - 60_000).toISOString();
98
+ const justNow = () => new Date().toISOString();
99
+
100
+ describe('ReconcileOrphanedConversationDetails', () => {
101
+ // Block body, NOT a concise arrow: `mockReset()` returns the mock, which is callable, and
102
+ // vitest treats a function returned from beforeEach as a teardown hook — so a concise arrow
103
+ // hands vitest the mock itself to invoke with no arguments after every test.
104
+ beforeEach(() => {
105
+ mockRunView.mockReset();
106
+ mockGetEntityObject.mockReset();
107
+ writableStore.last = null;
108
+ });
109
+
110
+ it('closes a detail whose run failed long ago', async () => {
111
+ const d = detail();
112
+ script([d], [{ ConversationDetailID: DETAIL_ID, Status: 'Failed', CompletedAt: longAgo(), ErrorMessage: 'watchdog force-fail' }]);
113
+
114
+ const closed = await ReconcileOrphanedConversationDetails(PROVIDER, USER);
115
+
116
+ expect(closed).toBe(1);
117
+ expect(writableStore.last?.Status).toBe('Error');
118
+ expect(writableStore.last?.Message).toBe('watchdog force-fail');
119
+ });
120
+
121
+ it('falls back to a failure marker only when the failed run carries no error text', async () => {
122
+ script([detail()], [{ ConversationDetailID: DETAIL_ID, Status: 'Failed', CompletedAt: longAgo() }]);
123
+
124
+ await ReconcileOrphanedConversationDetails(PROVIDER, USER);
125
+
126
+ expect(writableStore.last?.Status).toBe('Error');
127
+ expect(writableStore.last?.Message).toBe('❌ Failed');
128
+ });
129
+
130
+ it('marks a completed run as Complete, not Error', async () => {
131
+ const d = detail();
132
+ script([d], [{ ConversationDetailID: DETAIL_ID, Status: 'Completed', CompletedAt: longAgo() }]);
133
+
134
+ await ReconcileOrphanedConversationDetails(PROVIDER, USER);
135
+
136
+ expect(writableStore.last?.Status).toBe('Complete');
137
+ // A successful run has no error to report, so an empty message stays empty rather than
138
+ // carrying a failure marker that contradicts the status beside it.
139
+ expect(writableStore.last?.Message).toBe('');
140
+ });
141
+
142
+ it('marks a run that ended awaiting the user\'s reply as Complete', async () => {
143
+ // AwaitingFeedback is how a normal chat turn ends: the run succeeded and the agent's
144
+ // reply is the message. A process that dies before closing the detail leaves exactly
145
+ // this pair behind, and the stale-run sweep never touches it because it is not Running.
146
+ script([detail()], [{ ConversationDetailID: DETAIL_ID, Status: 'AwaitingFeedback', CompletedAt: longAgo() }]);
147
+
148
+ expect(await ReconcileOrphanedConversationDetails(PROVIDER, USER)).toBe(1);
149
+ expect(writableStore.last?.Status).toBe('Complete');
150
+ expect(writableStore.last?.Message).toBe('');
151
+ });
152
+
153
+ it('leaves a message alone while its run is parked on a workflow', async () => {
154
+ // Paused means a task graph is still executing on the run's behalf.
155
+ script([detail()], [{ ConversationDetailID: DETAIL_ID, Status: 'Paused', CompletedAt: null }]);
156
+
157
+ expect(await ReconcileOrphanedConversationDetails(PROVIDER, USER)).toBe(0);
158
+ expect(mockGetEntityObject).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('leaves a message alone while its run is still executing', async () => {
162
+ const d = detail();
163
+ script([d], [{ ConversationDetailID: DETAIL_ID, Status: 'Running', CompletedAt: null }]);
164
+
165
+ expect(await ReconcileOrphanedConversationDetails(PROVIDER, USER)).toBe(0);
166
+ expect(mockGetEntityObject).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it('does not race the normal completion path inside the grace window', async () => {
170
+ const d = detail();
171
+ script([d], [{ ConversationDetailID: DETAIL_ID, Status: 'Completed', CompletedAt: justNow() }]);
172
+
173
+ expect(await ReconcileOrphanedConversationDetails(PROVIDER, USER)).toBe(0);
174
+ expect(mockGetEntityObject).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('leaves a message alone when no run exists yet', async () => {
178
+ const d = detail();
179
+ script([d], []);
180
+
181
+ expect(await ReconcileOrphanedConversationDetails(PROVIDER, USER)).toBe(0);
182
+ expect(mockGetEntityObject).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it('judges a retried message on its newest attempt, not an older finished one', async () => {
186
+ const d = detail();
187
+ // DESC order: newest first. The newest attempt is still running.
188
+ script([d], [
189
+ { ConversationDetailID: DETAIL_ID, Status: 'Running', CompletedAt: null },
190
+ { ConversationDetailID: DETAIL_ID, Status: 'Failed', CompletedAt: longAgo() },
191
+ ]);
192
+
193
+ expect(await ReconcileOrphanedConversationDetails(PROVIDER, USER)).toBe(0);
194
+ });
195
+
196
+ it('preserves an existing message rather than overwriting the answer', async () => {
197
+ script([detail()], [{ ConversationDetailID: DETAIL_ID, Status: 'Completed', CompletedAt: longAgo() }]);
198
+ mockGetEntityObject.mockImplementation(async () => {
199
+ const w = writable();
200
+ w.Message = 'the real answer';
201
+ return w;
202
+ });
203
+
204
+ await ReconcileOrphanedConversationDetails(PROVIDER, USER);
205
+
206
+ // The loaded record already carries the answer; only the status should change.
207
+ expect(writableStore.last?.Message).toBe('the real answer');
208
+ expect(writableStore.last?.Status).toBe('Complete');
209
+ });
210
+
211
+ it('reports a refused save without throwing', async () => {
212
+ script([detail()], [{ ConversationDetailID: DETAIL_ID, Status: 'Failed', CompletedAt: longAgo() }]);
213
+ mockGetEntityObject.mockImplementation(async () => writable(false));
214
+
215
+ expect(await ReconcileOrphanedConversationDetails(PROVIDER, USER)).toBe(0);
216
+ });
217
+
218
+ it('writes as the conversation OWNER, never as the calling system user', async () => {
219
+ // REGRESSION, found only by running it against real data. The permission gate on
220
+ // MJ: Conversation Details refuses a non-owner and returns false with NO LatestResult, so
221
+ // every save was silently rejected while the reconciler reported success-shaped logs. The
222
+ // system user this pass runs as owns nothing, which is exactly who that gate stops.
223
+ script([detail()], [{ ConversationDetailID: DETAIL_ID, Status: 'Failed', CompletedAt: longAgo() }]);
224
+
225
+ await ReconcileOrphanedConversationDetails(PROVIDER, USER);
226
+
227
+ expect(mockGetEntityObject).toHaveBeenCalledWith(
228
+ 'MJ: Conversation Details',
229
+ expect.objectContaining({ ID: 'OWNER-1' })
230
+ );
231
+ });
232
+
233
+ it('leaves the detail alone when no owner can be resolved', async () => {
234
+ script([detail()], [{ ConversationDetailID: DETAIL_ID, Status: 'Failed', CompletedAt: longAgo() }], null);
235
+
236
+ expect(await ReconcileOrphanedConversationDetails(PROVIDER, USER)).toBe(0);
237
+ expect(mockGetEntityObject).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it('never throws out of a maintenance pass', async () => {
241
+ mockRunView.mockImplementation(async () => { throw new Error('db down'); });
242
+
243
+ await expect(ReconcileOrphanedConversationDetails(PROVIDER, USER)).resolves.toBe(0);
244
+ });
245
+
246
+ it('asks only for details that have a finished run, so stuck rows cannot fill the window', async () => {
247
+ // Details with no run, or with a run still executing, are never closed here. Read without
248
+ // this filter, 200 of them pin the oldest-first window and no closable orphan is reached.
249
+ script([detail()], [{ ConversationDetailID: DETAIL_ID, Status: 'Failed', CompletedAt: longAgo() }]);
250
+
251
+ await ReconcileOrphanedConversationDetails(PROVIDER, USER);
252
+
253
+ const detailCall = mockRunView.mock.calls.find(
254
+ c => (c[0] as RunViewParams).EntityName === 'MJ: Conversation Details'
255
+ );
256
+ expect((detailCall?.[0] as RunViewParams).ExtraFilter).toBe(
257
+ "Status = 'In-Progress' AND Role = 'AI' AND ID IN (SELECT [ConversationDetailID] FROM [__mj].[vwAIAgentRuns] " +
258
+ "WHERE [Status] IN ('Completed','AwaitingFeedback','Failed','Cancelled'))"
259
+ );
260
+ });
261
+
262
+ it('leaves a detail alone when something else closed it after the list query', async () => {
263
+ // The normal completion path can close the row between our read and our write. Writing
264
+ // anyway would stamp this pass's outcome over the one that actually happened.
265
+ script(
266
+ [detail()],
267
+ [{ ConversationDetailID: DETAIL_ID, Status: 'Failed', CompletedAt: longAgo(), ErrorMessage: 'late' }],
268
+ 'OWNER-1',
269
+ 'Complete'
270
+ );
271
+
272
+ const closed = await ReconcileOrphanedConversationDetails(PROVIDER, USER);
273
+
274
+ expect(closed).toBe(0);
275
+ expect(writableStore.last?.Status).toBe('Complete');
276
+ expect((writableStore.last?.Save as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
277
+ });
278
+
279
+ it('does nothing when no message is in progress', async () => {
280
+ script([], []);
281
+ expect(await ReconcileOrphanedConversationDetails(PROVIDER, USER)).toBe(0);
282
+ });
283
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Tests for cross-instance fan-out of push-status updates (MJ #4222).
3
+ *
4
+ * Behind a load balancer the browser's WebSocket lives on one replica while the mutation driving
5
+ * the agent may be handled by another, so a completion can be published to a topic that has no
6
+ * subscriber. Replicating progress and completion closes that.
7
+ *
8
+ * Three properties are pinned here because each one, if wrong, produces a bug that looks like
9
+ * something else entirely:
10
+ *
11
+ * - **Streaming content is not replicated.** It is hundreds of messages per second, each carrying
12
+ * a full serialized agent run. Replicating it would look like a network problem, not a bug here.
13
+ * - **Every publish carries `SourceServerId`.** Without it, echo suppression on the receiving side
14
+ * has nothing to match and every push is delivered twice.
15
+ * - **A hook that throws does not break local delivery.** The message bus is an optimization over
16
+ * the durable tail query; the local path must work when it is down.
17
+ */
18
+ import 'reflect-metadata';
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
20
+ import {
21
+ publishStatusUpdate,
22
+ ParseReplicatedStatusUpdate,
23
+ ShouldReplicateStatusUpdate,
24
+ SetPushStatusPublishHook,
25
+ type PushStatusNotificationPayload,
26
+ } from '../generic/PushStatusResolver.js';
27
+ import { MJGlobal } from '@memberjunction/global';
28
+ import type { PubSubEngine } from 'type-graphql';
29
+
30
+ const SESSION = 'session-1';
31
+ const OWNER = 'U0000000-0000-0000-0000-000000000000';
32
+
33
+ function fakePubSub() {
34
+ const published: Array<{ topic: string; payload: PushStatusNotificationPayload }> = [];
35
+ const engine = {
36
+ publish: (topic: string, payload: PushStatusNotificationPayload) => {
37
+ published.push({ topic, payload });
38
+ return Promise.resolve();
39
+ },
40
+ } as unknown as PubSubEngine;
41
+ return { engine, published };
42
+ }
43
+
44
+ const envelope = (type: string) => JSON.stringify({ resolver: 'RunAIAgentResolver', type, status: 'ok', data: {} });
45
+
46
+ afterEach(() => {
47
+ SetPushStatusPublishHook(undefined);
48
+ });
49
+
50
+ describe('ShouldReplicateStatusUpdate', () => {
51
+ it('replicates execution progress', () => {
52
+ expect(ShouldReplicateStatusUpdate(envelope('ExecutionProgress'))).toBe(true);
53
+ });
54
+
55
+ it('does not replicate streaming content', () => {
56
+ expect(ShouldReplicateStatusUpdate(envelope('StreamingContent'))).toBe(false);
57
+ });
58
+
59
+ it('replicates a plain-string message, which is rare and low-rate', () => {
60
+ expect(ShouldReplicateStatusUpdate('agent finished')).toBe(true);
61
+ });
62
+
63
+ it('does not replicate an empty message', () => {
64
+ expect(ShouldReplicateStatusUpdate(undefined)).toBe(false);
65
+ expect(ShouldReplicateStatusUpdate('')).toBe(false);
66
+ });
67
+
68
+ it('replicates JSON that is not the resolver envelope', () => {
69
+ expect(ShouldReplicateStatusUpdate(JSON.stringify({ hello: 'world' }))).toBe(true);
70
+ });
71
+ });
72
+
73
+ describe('publishStatusUpdate fan-out', () => {
74
+ beforeEach(() => {
75
+ SetPushStatusPublishHook(undefined);
76
+ });
77
+
78
+ it('stamps the publishing instance on every payload', () => {
79
+ const { engine, published } = fakePubSub();
80
+
81
+ publishStatusUpdate(engine, { sessionId: SESSION, ownerUserId: OWNER, message: envelope('ExecutionProgress') });
82
+
83
+ expect(published).toHaveLength(1);
84
+ expect(published[0].payload.SourceServerId).toBe(MJGlobal.Instance.ProcessUUID);
85
+ });
86
+
87
+ it('still publishes locally when no hook is registered', () => {
88
+ const { engine, published } = fakePubSub();
89
+
90
+ publishStatusUpdate(engine, { sessionId: SESSION, ownerUserId: OWNER, message: envelope('ExecutionProgress') });
91
+
92
+ expect(published[0].payload.ownerUserId).toBe(OWNER);
93
+ });
94
+
95
+ it('forwards a replicable update to the hook', () => {
96
+ const hook = vi.fn();
97
+ SetPushStatusPublishHook(hook);
98
+ const { engine } = fakePubSub();
99
+
100
+ publishStatusUpdate(engine, { sessionId: SESSION, ownerUserId: OWNER, message: envelope('ExecutionProgress') });
101
+
102
+ expect(hook).toHaveBeenCalledTimes(1);
103
+ const forwarded = hook.mock.calls[0][0] as PushStatusNotificationPayload;
104
+ expect(forwarded.sessionId).toBe(SESSION);
105
+ expect(forwarded.ownerUserId).toBe(OWNER);
106
+ expect(forwarded.SourceServerId).toBe(MJGlobal.Instance.ProcessUUID);
107
+ });
108
+
109
+ it('does not forward streaming content, but still delivers it locally', () => {
110
+ const hook = vi.fn();
111
+ SetPushStatusPublishHook(hook);
112
+ const { engine, published } = fakePubSub();
113
+
114
+ publishStatusUpdate(engine, { sessionId: SESSION, ownerUserId: OWNER, message: envelope('StreamingContent') });
115
+
116
+ expect(hook).not.toHaveBeenCalled();
117
+ expect(published).toHaveLength(1);
118
+ });
119
+
120
+ it('delivers locally even when the hook throws', () => {
121
+ SetPushStatusPublishHook(() => {
122
+ throw new Error('redis is down');
123
+ });
124
+ const { engine, published } = fakePubSub();
125
+
126
+ expect(() =>
127
+ publishStatusUpdate(engine, { sessionId: SESSION, ownerUserId: OWNER, message: envelope('ExecutionProgress') })
128
+ ).not.toThrow();
129
+ expect(published).toHaveLength(1);
130
+ });
131
+
132
+ it('publishes locally before attempting fan-out', () => {
133
+ const order: string[] = [];
134
+ SetPushStatusPublishHook(() => order.push('hook'));
135
+ const engine = {
136
+ publish: () => {
137
+ order.push('local');
138
+ return Promise.resolve();
139
+ },
140
+ } as unknown as PubSubEngine;
141
+
142
+ publishStatusUpdate(engine, { sessionId: SESSION, ownerUserId: OWNER, message: envelope('ExecutionProgress') });
143
+
144
+ expect(order).toEqual(['local', 'hook']);
145
+ });
146
+
147
+ it('clears the hook when set with no argument', () => {
148
+ const hook = vi.fn();
149
+ SetPushStatusPublishHook(hook);
150
+ SetPushStatusPublishHook(undefined);
151
+ const { engine } = fakePubSub();
152
+
153
+ publishStatusUpdate(engine, { sessionId: SESSION, ownerUserId: OWNER, message: envelope('ExecutionProgress') });
154
+
155
+ expect(hook).not.toHaveBeenCalled();
156
+ });
157
+ });
158
+
159
+ describe('ParseReplicatedStatusUpdate', () => {
160
+ /**
161
+ * Messages on the shared channel come from other processes, so every field is checked before
162
+ * the update is republished onto the local topic, where the subscription filter compares
163
+ * `ownerUserId` against the connection's user.
164
+ */
165
+ const LOCAL = 'LOCAL-PROCESS';
166
+ const raw = (over: Record<string, unknown> = {}) =>
167
+ JSON.stringify({ sessionId: SESSION, ownerUserId: OWNER, message: 'hello', SourceServerId: 'OTHER', ...over });
168
+
169
+ it('returns the update from another instance', () => {
170
+ expect(ParseReplicatedStatusUpdate(raw(), LOCAL)).toEqual({
171
+ sessionId: SESSION,
172
+ ownerUserId: OWNER,
173
+ message: 'hello',
174
+ SourceServerId: 'OTHER',
175
+ });
176
+ });
177
+
178
+ it('drops our own message echoed back by the bus', () => {
179
+ expect(ParseReplicatedStatusUpdate(raw({ SourceServerId: LOCAL }), LOCAL)).toBeNull();
180
+ });
181
+
182
+ it('drops an update with no identity', () => {
183
+ expect(ParseReplicatedStatusUpdate(raw({ ownerUserId: '' }), LOCAL)).toBeNull();
184
+ expect(ParseReplicatedStatusUpdate(raw({ sessionId: undefined }), LOCAL)).toBeNull();
185
+ });
186
+
187
+ it('drops identity fields that are not strings', () => {
188
+ // A truthy object would pass a presence check and reach the filter as the wrong type.
189
+ expect(ParseReplicatedStatusUpdate(raw({ ownerUserId: { ID: OWNER } }), LOCAL)).toBeNull();
190
+ expect(ParseReplicatedStatusUpdate(raw({ sessionId: 42 }), LOCAL)).toBeNull();
191
+ });
192
+
193
+ it('drops a message body that is not a string', () => {
194
+ expect(ParseReplicatedStatusUpdate(raw({ message: { type: 'x' } }), LOCAL)).toBeNull();
195
+ });
196
+
197
+ it('accepts an update with no message body', () => {
198
+ expect(ParseReplicatedStatusUpdate(raw({ message: undefined }), LOCAL)?.message).toBeUndefined();
199
+ });
200
+
201
+ it('drops malformed JSON and values that are not objects', () => {
202
+ expect(ParseReplicatedStatusUpdate('{not json', LOCAL)).toBeNull();
203
+ expect(ParseReplicatedStatusUpdate('null', LOCAL)).toBeNull();
204
+ expect(ParseReplicatedStatusUpdate('42', LOCAL)).toBeNull();
205
+ });
206
+ });
@@ -21,6 +21,7 @@ describe('Authentication Provider Backward Compatibility', () => {
21
21
  let factory: AuthProviderFactory;
22
22
 
23
23
  beforeEach(() => {
24
+ vi.spyOn(AuthProviderFactory, 'DiscoverFromEnvironment').mockReturnValue([]);
24
25
  factory = AuthProviderFactory.Instance;
25
26
  factory.clear();
26
27
  });