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

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 (220) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +2189 -71
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +123 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +11 -23
  9. package/dist/context.js.map +1 -1
  10. package/dist/generated/graphql-schemas/__mj.d.ts +2143 -205
  11. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  12. package/dist/generated/graphql-schemas/__mj.js +24441 -14433
  13. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  14. package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
  15. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  16. package/dist/generic/FireAndForgetHeartbeat.js +16 -2
  17. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  18. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
  19. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
  20. package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
  21. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
  22. package/dist/generic/PushStatusResolver.d.ts +38 -0
  23. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  24. package/dist/generic/PushStatusResolver.js +84 -1
  25. package/dist/generic/PushStatusResolver.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +56 -4
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +191 -74
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/generic/RunViewResolver.d.ts +4 -0
  31. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  32. package/dist/generic/RunViewResolver.js +33 -1
  33. package/dist/generic/RunViewResolver.js.map +1 -1
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +204 -50
  37. package/dist/index.js.map +1 -1
  38. package/dist/postgresqlCompat.d.ts +8 -0
  39. package/dist/postgresqlCompat.d.ts.map +1 -0
  40. package/dist/postgresqlCompat.js +19 -0
  41. package/dist/postgresqlCompat.js.map +1 -0
  42. package/dist/postgresqlPoolSettings.d.ts +108 -0
  43. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  44. package/dist/postgresqlPoolSettings.js +134 -0
  45. package/dist/postgresqlPoolSettings.js.map +1 -0
  46. package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
  47. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  48. package/dist/resolvers/AdhocQueryResolver.js +77 -167
  49. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  50. package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
  51. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
  52. package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
  53. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
  54. package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
  55. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
  56. package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
  57. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
  58. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
  59. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
  60. package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
  61. package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
  62. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  63. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  64. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  65. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  66. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  67. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
  68. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  69. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  70. package/dist/resolvers/GetDataResolver.js +9 -0
  71. package/dist/resolvers/GetDataResolver.js.map +1 -1
  72. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  73. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  74. package/dist/resolvers/HumanHandoffResolver.js +324 -0
  75. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  76. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  77. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  78. package/dist/resolvers/MeetingResolver.js +381 -0
  79. package/dist/resolvers/MeetingResolver.js.map +1 -0
  80. package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
  81. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  82. package/dist/resolvers/MergeRecordsResolver.js +16 -2
  83. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  84. package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
  85. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  86. package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
  87. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +45 -89
  89. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  90. package/dist/resolvers/RealtimeClientSessionResolver.js +94 -240
  91. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  92. package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
  93. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  94. package/dist/resolvers/RunAIAgentResolver.js +23 -153
  95. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  96. package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
  97. package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
  98. package/dist/resolvers/RunDecisionResolver.js +396 -0
  99. package/dist/resolvers/RunDecisionResolver.js.map +1 -0
  100. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  101. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  102. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  103. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  104. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  105. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  106. package/dist/resolvers/adhocTimeout.js +12 -0
  107. package/dist/resolvers/adhocTimeout.js.map +1 -0
  108. package/dist/resolvers/meetings/index.d.ts +5 -0
  109. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  110. package/dist/resolvers/meetings/index.js +5 -0
  111. package/dist/resolvers/meetings/index.js.map +1 -0
  112. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  113. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  114. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  115. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  116. package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
  117. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  118. package/dist/resolvers/meetings/meetingMutations.js +418 -0
  119. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  120. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  121. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  122. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  123. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  124. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  125. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  126. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  127. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  128. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  129. package/dist/rest/ViewOperationsHandler.js +8 -5
  130. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  131. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  132. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  133. package/dist/services/TaskGraphActionRunner.js +27 -1
  134. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  135. package/dist/services/WorkQueueHostService.d.ts +36 -0
  136. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  137. package/dist/services/WorkQueueHostService.js +97 -0
  138. package/dist/services/WorkQueueHostService.js.map +1 -0
  139. package/dist/services/workQueueConfig.d.ts +103 -0
  140. package/dist/services/workQueueConfig.d.ts.map +1 -0
  141. package/dist/services/workQueueConfig.js +48 -0
  142. package/dist/services/workQueueConfig.js.map +1 -0
  143. package/dist/types.d.ts +5 -0
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/types.js.map +1 -1
  146. package/package.json +95 -92
  147. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  148. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  149. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  150. package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
  151. package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
  152. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  153. package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
  154. package/src/__tests__/MeetingResolver.test.ts +374 -0
  155. package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
  156. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  157. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
  158. package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
  159. package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
  160. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  161. package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
  162. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  163. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  164. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
  165. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
  166. package/src/__tests__/RunDecisionResolver.test.ts +699 -0
  167. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +43 -0
  168. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  169. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  170. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  171. package/src/__tests__/conversationRunEvents.test.ts +399 -0
  172. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
  173. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  174. package/src/__tests__/pushStatusFanOut.test.ts +206 -0
  175. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  176. package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
  177. package/src/config.ts +130 -3
  178. package/src/context.ts +11 -24
  179. package/src/generated/graphql-schemas/__mj.ts +24183 -17182
  180. package/src/generic/FireAndForgetHeartbeat.ts +16 -2
  181. package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
  182. package/src/generic/PushStatusResolver.ts +97 -1
  183. package/src/generic/ResolverBase.ts +220 -76
  184. package/src/generic/RunViewResolver.ts +33 -2
  185. package/src/index.ts +228 -51
  186. package/src/postgresqlCompat.ts +21 -0
  187. package/src/postgresqlPoolSettings.ts +213 -0
  188. package/src/resolvers/AdhocQueryResolver.ts +88 -188
  189. package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
  190. package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
  191. package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
  192. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  193. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
  194. package/src/resolvers/GetDataResolver.ts +10 -0
  195. package/src/resolvers/HumanHandoffResolver.ts +304 -0
  196. package/src/resolvers/MeetingResolver.ts +438 -0
  197. package/src/resolvers/MergeRecordsResolver.ts +14 -2
  198. package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
  199. package/src/resolvers/RealtimeClientSessionResolver.ts +99 -287
  200. package/src/resolvers/RunAIAgentResolver.ts +28 -174
  201. package/src/resolvers/RunDecisionResolver.ts +435 -0
  202. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  203. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
  204. package/src/resolvers/adhocTimeout.ts +12 -0
  205. package/src/resolvers/meetings/index.ts +4 -0
  206. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  207. package/src/resolvers/meetings/meetingMutations.ts +535 -0
  208. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  209. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  210. package/src/rest/ViewOperationsHandler.ts +8 -5
  211. package/src/services/TaskGraphActionRunner.ts +29 -1
  212. package/src/services/WorkQueueHostService.ts +129 -0
  213. package/src/services/workQueueConfig.ts +52 -0
  214. package/src/types.ts +5 -0
  215. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  216. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  217. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  218. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  219. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  220. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Tests for TailConversationEvents — the durable, resumable read of an agent run's
3
+ * progress (MJ #4222).
4
+ *
5
+ * The live `statusUpdates` subscription has no replay, so anything published while a
6
+ * socket is half-open is lost and the client cannot tell "nothing happened" from "I
7
+ * missed everything". This query is the durable counterpart. The properties that make
8
+ * it safe to rely on are pinned here: a cursor that never rewinds, a terminal payload
9
+ * that is never handed over early, and an authorization answer that does not leak
10
+ * whether a record exists.
11
+ *
12
+ * Mocking is at the package boundary — `@memberjunction/core`'s RunView, which is the
13
+ * only I/O the resolver performs. The resolver itself runs unmodified.
14
+ */
15
+ import 'reflect-metadata';
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
+ import type { RunViewParams, UserInfo } from '@memberjunction/core';
18
+
19
+ const { mockRunView, providersUsed } = vi.hoisted(() => ({ mockRunView: vi.fn(), providersUsed: [] as unknown[] }));
20
+
21
+ // RunView is the resolver's only runtime dependency on core — every other import it
22
+ // makes is type-only and erased at compile time, so a narrow mock is sufficient and
23
+ // keeps the real package (which loads config at import) out of the test.
24
+ vi.mock('@memberjunction/core', () => ({
25
+ RunView: class MockRunView {
26
+ constructor(provider?: unknown) {
27
+ providersUsed.push(provider);
28
+ }
29
+ public static FromMetadataProvider(provider: unknown): MockRunView {
30
+ return new MockRunView(provider);
31
+ }
32
+ public async RunView(params: RunViewParams, contextUser?: UserInfo): Promise<unknown> {
33
+ return mockRunView(params, contextUser);
34
+ }
35
+ },
36
+ }));
37
+
38
+ import { ConversationRunEventsResolver } from '../resolvers/ConversationRunEventsResolver';
39
+
40
+ const DETAIL_ID = 'A1111111-1111-1111-1111-111111111111';
41
+ const RUN_ID = 'B2222222-2222-2222-2222-222222222222';
42
+ const USER = { ID: 'U0000000-0000-0000-0000-000000000000', Email: 'someone@example.com' } as unknown as UserInfo;
43
+
44
+ const READ_WRITE_PROVIDER = { name: 'read-write' };
45
+ const READ_ONLY_PROVIDER = { name: 'read-only' };
46
+ const ctxFor = (user?: UserInfo) =>
47
+ ({
48
+ userPayload: { userRecord: user },
49
+ providers: [
50
+ { type: 'Read-Write', provider: READ_WRITE_PROVIDER },
51
+ { type: 'Read-Only', provider: READ_ONLY_PROVIDER },
52
+ ],
53
+ }) as never;
54
+
55
+ /** Scripts RunView per entity so each test states only what it cares about. */
56
+ function scriptRunView(opts: {
57
+ detail?: Record<string, unknown> | null;
58
+ run?: Record<string, unknown> | null;
59
+ steps?: Record<string, unknown>[];
60
+ }) {
61
+ mockRunView.mockImplementation(async (params: RunViewParams) => {
62
+ if (params.EntityName === 'MJ: Conversation Details') {
63
+ return { Success: true, Results: opts.detail ? [opts.detail] : [] };
64
+ }
65
+ if (params.EntityName === 'MJ: AI Agent Runs') {
66
+ return { Success: true, Results: opts.run ? [opts.run] : [] };
67
+ }
68
+ if (params.EntityName === 'MJ: AI Agent Run Steps') {
69
+ return { Success: true, Results: opts.steps ?? [] };
70
+ }
71
+ throw new Error(`unexpected entity ${params.EntityName}`);
72
+ });
73
+ }
74
+
75
+ const step = (seq: number, over: Record<string, unknown> = {}) => ({
76
+ StepNumber: seq,
77
+ StepType: 'Actions',
78
+ StepName: `Step ${seq}`,
79
+ Status: 'Completed',
80
+ Success: true,
81
+ StartedAt: new Date('2026-09-16T04:55:12Z'),
82
+ CompletedAt: new Date('2026-09-16T04:55:21Z'),
83
+ ...over,
84
+ });
85
+
86
+ describe('TailConversationEvents', () => {
87
+ let resolver: ConversationRunEventsResolver;
88
+
89
+ beforeEach(() => {
90
+ vi.clearAllMocks();
91
+ providersUsed.length = 0;
92
+ resolver = new ConversationRunEventsResolver();
93
+ });
94
+
95
+ describe('provider', () => {
96
+ it("reads through the request's read-only provider, not the process-wide one", async () => {
97
+ // The global provider is shared by every request, including its transaction state.
98
+ scriptRunView({
99
+ detail: { ID: DETAIL_ID, Status: 'In-Progress' },
100
+ run: { ID: RUN_ID, Status: 'Running' },
101
+ steps: [],
102
+ });
103
+
104
+ await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
105
+
106
+ expect(providersUsed.length).toBe(3);
107
+ expect(providersUsed.every((p) => p === READ_ONLY_PROVIDER)).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe('columns read', () => {
112
+ it('reads only the step columns an event carries', async () => {
113
+ // A step row holds InputData, OutputData and payload snapshots — the largest columns
114
+ // in the table. Loading 200 of them to return eight small fields wastes MJAPI memory.
115
+ scriptRunView({ detail: { ID: DETAIL_ID }, run: { ID: RUN_ID, Status: 'Running' }, steps: [] });
116
+ await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
117
+
118
+ const stepCall = mockRunView.mock.calls.find((c) => c[0].EntityName === 'MJ: AI Agent Run Steps');
119
+ expect(stepCall?.[0].ResultType).toBe('simple');
120
+ expect([...(stepCall?.[0].Fields ?? [])].sort()).toEqual(
121
+ ['CompletedAt', 'ErrorMessage', 'StartedAt', 'Status', 'StepName', 'StepNumber', 'StepType', 'Success']
122
+ );
123
+ });
124
+
125
+ it('reads only the run columns it uses', async () => {
126
+ scriptRunView({ detail: { ID: DETAIL_ID }, run: { ID: RUN_ID, Status: 'Running' }, steps: [] });
127
+ await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
128
+
129
+ const runCall = mockRunView.mock.calls.find((c) => c[0].EntityName === 'MJ: AI Agent Runs');
130
+ expect(runCall?.[0].ResultType).toBe('simple');
131
+ expect([...(runCall?.[0].Fields ?? [])].sort()).toEqual(['ID', 'Result', 'Status']);
132
+ });
133
+
134
+ it('returns real dates when a plain row carries them as strings', async () => {
135
+ scriptRunView({
136
+ detail: { ID: DETAIL_ID },
137
+ run: { ID: RUN_ID, Status: 'Running' },
138
+ steps: [step(1, { StartedAt: '2026-09-16T04:55:12.000Z', CompletedAt: '2026-09-16T04:55:21.000Z' })],
139
+ });
140
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
141
+
142
+ expect(out.Events[0].StartedAt).toBeInstanceOf(Date);
143
+ expect(out.Events[0].StartedAt.toISOString()).toBe('2026-09-16T04:55:12.000Z');
144
+ expect(out.Events[0].CompletedAt).toBeInstanceOf(Date);
145
+ });
146
+ });
147
+
148
+ describe('authorization', () => {
149
+ it('refuses an unauthenticated caller', async () => {
150
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(undefined), 0);
151
+ expect(out.Success).toBe(false);
152
+ expect(out.Message).toMatch(/not authenticated/i);
153
+ expect(mockRunView).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it('reads the conversation detail AS THE CALLING USER, so row-level security applies', async () => {
157
+ scriptRunView({ detail: { ID: DETAIL_ID, Status: 'In-Progress' }, run: null });
158
+ await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
159
+
160
+ const [params, contextUser] = mockRunView.mock.calls[0];
161
+ expect(params.EntityName).toBe('MJ: Conversation Details');
162
+ // Passing the user is the entire authorization mechanism. A system user here
163
+ // would silently expose every conversation in the instance.
164
+ expect(contextUser).toBe(USER);
165
+ });
166
+
167
+ it('bypasses the server RunView cache on every read', async () => {
168
+ // This is the last-resort recovery path, and the rows it judges are written by
169
+ // things that never fire a cache invalidation: `spSweepStaleAIAgentRuns` force-fails
170
+ // a run with a direct set-based UPDATE. `TrustServerCacheCompletely` defaults on, so
171
+ // a cached read here would keep reporting the run as Running and the recovery would
172
+ // never fire — the exact failure this resolver exists to end.
173
+ scriptRunView({
174
+ detail: { ID: DETAIL_ID, Status: 'In-Progress' },
175
+ run: { ID: RUN_ID, Status: 'Completed' },
176
+ steps: [],
177
+ });
178
+
179
+ await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
180
+
181
+ expect(mockRunView.mock.calls.length).toBeGreaterThan(0);
182
+ for (const [params] of mockRunView.mock.calls) {
183
+ expect(params.BypassCache, `${params.EntityName} must bypass the cache`).toBe(true);
184
+ }
185
+ });
186
+
187
+ it('gives an unreadable detail the same answer as a missing one', async () => {
188
+ scriptRunView({ detail: null });
189
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
190
+
191
+ // "Exists but denied" would confirm the id to someone probing for one.
192
+ expect(out.Success).toBe(false);
193
+ expect(out.Message).toMatch(/not found/i);
194
+ expect(out.Events).toEqual([]);
195
+ });
196
+ });
197
+
198
+ describe('cursor behavior', () => {
199
+ it('asks only for steps beyond the caller cursor', async () => {
200
+ scriptRunView({ detail: { ID: DETAIL_ID }, run: { ID: RUN_ID, Status: 'Running' }, steps: [] });
201
+ await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 7);
202
+
203
+ const stepCall = mockRunView.mock.calls.find((c) => c[0].EntityName === 'MJ: AI Agent Run Steps');
204
+ expect(stepCall?.[0].ExtraFilter).toContain('StepNumber > 7');
205
+ expect(stepCall?.[0].OrderBy).toBe('StepNumber ASC');
206
+ });
207
+
208
+ it('never rewinds the cursor when nothing new has landed', async () => {
209
+ scriptRunView({ detail: { ID: DETAIL_ID }, run: { ID: RUN_ID, Status: 'Running' }, steps: [] });
210
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 12);
211
+
212
+ // Returning 0 here would make a polling client replay the whole run every tick.
213
+ expect(out.LatestSeq).toBe(12);
214
+ expect(out.Events).toEqual([]);
215
+ });
216
+
217
+ it('advances the cursor to the last event returned', async () => {
218
+ scriptRunView({
219
+ detail: { ID: DETAIL_ID },
220
+ run: { ID: RUN_ID, Status: 'Running' },
221
+ steps: [step(3), step(4), step(5)],
222
+ });
223
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 2);
224
+
225
+ expect(out.Events.map((e) => e.Seq)).toEqual([3, 4, 5]);
226
+ expect(out.LatestSeq).toBe(5);
227
+ });
228
+
229
+ it('treats a negative cursor as the start of the run', async () => {
230
+ scriptRunView({ detail: { ID: DETAIL_ID }, run: { ID: RUN_ID, Status: 'Running' }, steps: [] });
231
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), -5);
232
+
233
+ const stepCall = mockRunView.mock.calls.find((c) => c[0].EntityName === 'MJ: AI Agent Run Steps');
234
+ expect(stepCall?.[0].ExtraFilter).toContain('StepNumber > 0');
235
+ expect(out.LatestSeq).toBe(0);
236
+ });
237
+
238
+ it('bounds how much one call can return', async () => {
239
+ scriptRunView({ detail: { ID: DETAIL_ID }, run: { ID: RUN_ID, Status: 'Running' }, steps: [] });
240
+ await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
241
+
242
+ // A run can hold hundreds of steps. Unbounded, one tail could return tens of
243
+ // megabytes — the same failure class as #4539.
244
+ const stepCall = mockRunView.mock.calls.find((c) => c[0].EntityName === 'MJ: AI Agent Run Steps');
245
+ expect(stepCall?.[0].MaxRows).toBe(200);
246
+ });
247
+ });
248
+
249
+ describe('run lifecycle', () => {
250
+ it('reports an early poll as success, not failure, when the run row does not exist yet', async () => {
251
+ scriptRunView({ detail: { ID: DETAIL_ID, Status: 'In-Progress' }, run: null });
252
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
253
+
254
+ // Fire-and-forget acks before the run row is written, so this is an ordinary
255
+ // race. Reporting failure would make a client abandon a run that is about to start.
256
+ expect(out.Success).toBe(true);
257
+ expect(out.IsInFlight).toBe(false);
258
+ expect(out.DetailStatus).toBe('In-Progress');
259
+ });
260
+
261
+ it('marks a Running run in flight and withholds the payload', async () => {
262
+ scriptRunView({
263
+ detail: { ID: DETAIL_ID, Status: 'In-Progress' },
264
+ run: { ID: RUN_ID, Status: 'Running', Result: '{"partial":true}' },
265
+ steps: [step(1)],
266
+ });
267
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
268
+
269
+ expect(out.IsInFlight).toBe(true);
270
+ // A partial Result mid-run could be mistaken for the final answer.
271
+ expect(out.FinalPayload).toBeUndefined();
272
+ });
273
+
274
+ it('releases the payload once the run is terminal, so one call ends the wait', async () => {
275
+ scriptRunView({
276
+ detail: { ID: DETAIL_ID, Status: 'Complete' },
277
+ run: { ID: RUN_ID, Status: 'Completed', Result: '{"answer":"done"}' },
278
+ steps: [step(1)],
279
+ });
280
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
281
+
282
+ expect(out.IsInFlight).toBe(false);
283
+ expect(out.FinalPayload).toBe('{"answer":"done"}');
284
+ expect(out.RunStatus).toBe('Completed');
285
+ expect(out.DetailStatus).toBe('Complete');
286
+ });
287
+
288
+ it('treats a failed run as terminal so a client stops tailing', async () => {
289
+ scriptRunView({
290
+ detail: { ID: DETAIL_ID, Status: 'Error' },
291
+ run: { ID: RUN_ID, Status: 'Failed', Result: null },
292
+ steps: [step(1, { Status: 'Failed', Success: false, ErrorMessage: 'boom' })],
293
+ });
294
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
295
+
296
+ expect(out.IsInFlight).toBe(false);
297
+ expect(out.Events[0].ErrorMessage).toBe('boom');
298
+ });
299
+
300
+ it('treats a Paused run as in flight, because it is parked on a workflow that is still running', async () => {
301
+ // The orphan reconciler and the process panel both treat Paused as not finished.
302
+ // Reporting it as terminal would tell a client to complete a message mid-workflow.
303
+ scriptRunView({
304
+ detail: { ID: DETAIL_ID, Status: 'In-Progress' },
305
+ run: { ID: RUN_ID, Status: 'Paused', Result: '{"started":"workflow"}' },
306
+ steps: [step(1)],
307
+ });
308
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
309
+
310
+ expect(out.IsInFlight).toBe(true);
311
+ expect(out.FinalPayload).toBeUndefined();
312
+ });
313
+
314
+ it('selects the NEWEST run, so a retried detail does not resolve from a dead run', async () => {
315
+ scriptRunView({ detail: { ID: DETAIL_ID }, run: { ID: RUN_ID, Status: 'Running' }, steps: [] });
316
+ await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
317
+
318
+ const runCall = mockRunView.mock.calls.find((c) => c[0].EntityName === 'MJ: AI Agent Runs');
319
+ expect(runCall?.[0].OrderBy).toMatch(/DESC/i);
320
+ expect(runCall?.[0].MaxRows).toBe(1);
321
+ });
322
+ });
323
+
324
+ describe('robustness', () => {
325
+ it('returns a failure result rather than throwing when a read blows up', async () => {
326
+ mockRunView.mockRejectedValue(new Error('db unavailable'));
327
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 4);
328
+
329
+ // This is a recovery path. Throwing would break the very reconciliation it exists
330
+ // to serve, and would rewind the caller's cursor.
331
+ expect(out.Success).toBe(false);
332
+ expect(out.Message).toMatch(/db unavailable/);
333
+ expect(out.LatestSeq).toBe(4);
334
+ });
335
+
336
+ it('reports a failed read as still in flight, matching the client', async () => {
337
+ // A failure knows nothing about the run. "Not in flight" would claim it is over.
338
+ mockRunView.mockRejectedValue(new Error('db unavailable'));
339
+ const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 4);
340
+
341
+ expect(out.IsInFlight).toBe(true);
342
+ });
343
+
344
+ it('escapes quotes in the id instead of interpolating them into the filter', async () => {
345
+ scriptRunView({ detail: null });
346
+ await resolver.TailConversationEvents("x' OR '1'='1", ctxFor(USER), 0);
347
+
348
+ const params = mockRunView.mock.calls[0][0];
349
+ expect(params.ExtraFilter).toContain("''");
350
+ expect(params.ExtraFilter).not.toMatch(/ID='x' OR '1'='1'/);
351
+ });
352
+ });
353
+ });
354
+
355
+ /**
356
+ * REGRESSION, found in manual testing. `FinalPayload` read only `AIAgentRun.Result`, which is
357
+ * agent-dependent — null on 16 of 40 completed runs in a real database, including every run of the
358
+ * Loop-type agent used to reproduce #4222. A caller waiting on this field to decide a message was
359
+ * finished would wait forever on those agents.
360
+ */
361
+ describe('TailConversationEvents FinalPayload sourcing', () => {
362
+ it('falls back to the conversation detail message when the run has no Result', async () => {
363
+ scriptRunView({
364
+ detail: { ID: DETAIL_ID, Status: 'Complete', Message: 'the answer text' },
365
+ run: { ID: RUN_ID, Status: 'Completed', Result: null },
366
+ steps: [],
367
+ });
368
+
369
+ const result = await new ConversationRunEventsResolver().TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
370
+
371
+ expect(result.IsInFlight).toBe(false);
372
+ expect(result.FinalPayload).toBe('the answer text');
373
+ });
374
+
375
+ it('prefers the run Result when the agent does write one', async () => {
376
+ scriptRunView({
377
+ detail: { ID: DETAIL_ID, Status: 'Complete', Message: 'detail message' },
378
+ run: { ID: RUN_ID, Status: 'Completed', Result: 'run result' },
379
+ steps: [],
380
+ });
381
+
382
+ const result = await new ConversationRunEventsResolver().TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
383
+
384
+ expect(result.FinalPayload).toBe('run result');
385
+ });
386
+
387
+ it('still withholds the payload while the run is in flight', async () => {
388
+ scriptRunView({
389
+ detail: { ID: DETAIL_ID, Status: 'In-Progress', Message: 'partial text' },
390
+ run: { ID: RUN_ID, Status: 'Running', Result: null },
391
+ steps: [],
392
+ });
393
+
394
+ const result = await new ConversationRunEventsResolver().TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
395
+
396
+ expect(result.IsInFlight).toBe(true);
397
+ expect(result.FinalPayload).toBeUndefined();
398
+ });
399
+ });
@@ -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
+ });