@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
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { Resolver, Mutation, Arg, Ctx, Int, Float, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
28
28
  import { AppContext, UserPayload } from '../types.js';
29
- import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView } from '@memberjunction/core';
29
+ import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView, BaseEntity } from '@memberjunction/core';
30
30
  import { UUIDsEqual, IsValidUUID } from '@memberjunction/global';
31
31
  import {
32
32
  MJAIAgentEntity,
@@ -48,6 +48,8 @@ import {
48
48
  FindIgnoredRealtimeConfigKeys,
49
49
  ParseRealtimeTypeConfiguration,
50
50
  ResolveEffectiveRealtimeConfig,
51
+ ResolveRealtimeCoAgentID,
52
+ FilterAllowedAgentsByCanRun,
51
53
  RealtimeAllowedAgent,
52
54
  RealtimeDirectActionsConfig,
53
55
  REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION,
@@ -56,7 +58,7 @@ import {
56
58
  writeRealtimeRecordingSegment,
57
59
  deleteRealtimeRecordingSegments,
58
60
  } from '@memberjunction/ai-agents';
59
- import { AgentExecutionProgressCallback, MJAIAgentEntityExtended, AppContextSnapshot } from '@memberjunction/ai-core-plus';
61
+ import { AgentExecutionProgressCallback, AppContextSnapshot } from '@memberjunction/ai-core-plus';
60
62
  import { ChatMessage, RealtimeToolDefinition } from '@memberjunction/ai';
61
63
  import { ResolverBase } from '../generic/ResolverBase.js';
62
64
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
@@ -71,32 +73,6 @@ import { ResolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../
71
73
  */
72
74
  const SIGNIFICANT_PROGRESS_STEPS = ['prompt_execution', 'action_execution', 'subagent_execution', 'decision_processing'];
73
75
 
74
- /**
75
- * The seeded name of the internal orchestration agent that fronts a target agent in realtime
76
- * sessions (voice + interactive channels). This is the GLOBAL DEFAULT co-agent — the final step of
77
- * the co-agent resolution chain (see {@link RealtimeClientSessionResolver.resolveCoAgentID}).
78
- * Deployments can override it per agent (`AIAgent.DefaultCoAgentID`), per agent type (an
79
- * `AIAgentCoAgent` row with `TargetAgentTypeID` + `IsDefault`), or per call (the `coAgentId`
80
- * mutation argument) without
81
- * touching this seed.
82
- */
83
- const REALTIME_CO_AGENT_NAME = 'Realtime Co-Agent';
84
-
85
- /**
86
- * DEPRECATED legacy seed name of {@link REALTIME_CO_AGENT_NAME}, from before the agent's rename
87
- * from "Voice Co-Agent" to "Realtime Co-Agent". Deployments that have not re-synced the agent seed
88
- * still carry this name, so {@link RealtimeClientSessionResolver.resolveGlobalCoAgentID} falls
89
- * back to it (with a deprecation log) when no agent named {@link REALTIME_CO_AGENT_NAME} exists.
90
- */
91
- const LEGACY_REALTIME_CO_AGENT_NAME = 'Voice Co-Agent';
92
-
93
- /**
94
- * The seeded name of the Realtime agent TYPE. Every co-agent candidate (explicit or metadata
95
- * default) must be an Active agent of this type — a co-agent drives a streaming full-duplex
96
- * realtime session, which only `RealtimeAgentType`-driven agents support.
97
- */
98
- const REALTIME_AGENT_TYPE_NAME = 'Realtime';
99
-
100
76
  /** Entity name — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
101
77
  const SESSION_ENTITY = 'MJ: AI Agent Sessions';
102
78
  const CO_AGENT_ENTITY = 'MJ: AI Agent Co Agents';
@@ -381,6 +357,31 @@ export class RealtimeClientSessionResolver extends ResolverBase {
381
357
  private readonly clientSessionService = new RealtimeClientSessionService();
382
358
  private readonly sessionManager = new SessionManager(this.clientSessionService);
383
359
 
360
+ /**
361
+ * The reason a `Save()`/`Delete()` returned false, for a log line. `CompleteMessage` renders
362
+ * `Message`, `Error` and every `Errors[]` entry (validation errors included, since `Save()`
363
+ * runs `Validate()` and records its errors there), so there is no separate `Validate()` call.
364
+ * When a subclass refused the write without recording anything, say so explicitly instead of
365
+ * "unknown error", which is what hid MJ#4791.
366
+ *
367
+ * Only a NULL `LatestResult` proves the refusal happened before the provider (nothing was
368
+ * registered at all). A SUCCESS `LatestResult` is an earlier save on the same instance — the
369
+ * refusal registered nothing — so its text must never be reported as this failure's reason.
370
+ */
371
+ private describeSaveFailure(entity: BaseEntity): string {
372
+ const latest = entity.LatestResult;
373
+ if (!latest) {
374
+ return `no failure detail recorded (LatestResult null; ResultHistory length ${entity.ResultHistory.length}) ` +
375
+ '— the write was refused before reaching the provider';
376
+ }
377
+ if (latest.Success) {
378
+ return 'no failure detail recorded (latest result entry is a prior success; ' +
379
+ `ResultHistory length ${entity.ResultHistory.length})`;
380
+ }
381
+ const detail = latest.CompleteMessage?.trim();
382
+ return detail || `no failure detail recorded (LatestResult empty; ResultHistory length ${entity.ResultHistory.length})`;
383
+ }
384
+
384
385
  /**
385
386
  * Start a client-direct realtime voice session targeting `targetAgentId`.
386
387
  *
@@ -389,7 +390,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
389
390
  * session is created.
390
391
  * 2. Resolve the co-agent id via the metadata-driven resolution chain (runtime `coAgentId` →
391
392
  * agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
392
- * see {@link RealtimeClientSessionResolver.resolveCoAgentID} for the full contract.
393
+ * see {@link ResolveRealtimeCoAgentID} for the full contract.
393
394
  * 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
394
395
  * config server-side — this is the authoritative target for all later relays.
395
396
  * 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
@@ -438,7 +439,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
438
439
  ): Promise<StartRealtimeClientSessionResult> {
439
440
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
440
441
 
441
- const coAgentID = await this.resolveCoAgentID(targetAgentId, coAgentId, contextUser, provider);
442
+ const coAgentID = await ResolveRealtimeCoAgentID(targetAgentId, coAgentId, contextUser, provider);
442
443
  // PAIRING CONSTRAINTS: a co-agent with pairing rows is restricted to that target list
443
444
  // (with the IsDefault row standing in when no runtime target was supplied); zero rows =
444
445
  // universal, today's behavior untouched. Resolves the AUTHORITATIVE target id.
@@ -553,7 +554,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
553
554
  TargetAgentID: config.targetAgentID,
554
555
  // Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
555
556
  // colleague in the call is validated against this; absent ⇒ single-target behavior.
556
- AllowedAgents: await this.filterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
557
+ AllowedAgents: await FilterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
557
558
  DirectActions: config.directActions,
558
559
  // Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
559
560
  // row and its context-memory scope must stay the person's, not the system user's.
@@ -593,7 +594,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
593
594
  // Junction-link any artifacts the delegated run produced into the session's conversation
594
595
  // history (best-effort) — so chat, session review, and resume carryover can all see them.
595
596
  // Runs as `runUser`: the junction entity is not among an anonymous caller's relay grants.
596
- await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, provider);
597
+ // The hidden anchor detail it may need to create, though, is written by the CALLER (#4791)
598
+ // — same reason `persistDirectActionTurn` is: the System user is not the conversation owner.
599
+ await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, contextUser, provider);
597
600
 
598
601
  await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
599
602
  return ResultJson;
@@ -657,7 +660,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
657
660
  session.Config_ = JSON.stringify(next);
658
661
  if (!(await session.Save())) {
659
662
  LogError(
660
- `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
663
+ `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
661
664
  );
662
665
  }
663
666
  }
@@ -1223,39 +1226,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1223
1226
  }
1224
1227
  }
1225
1228
 
1226
- /**
1227
- * Narrows a session's colleague union (`allowedAgents`) to the agents the CALLER may run.
1228
- *
1229
- * {@link assertCanRunTarget} gates the LEAD target at session start, but the union it travels
1230
- * with was never gated at all — a model-named colleague resolves straight to a delegated run.
1231
- * That was survivable while the run carried the caller's own identity, because base-agent
1232
- * re-checks `CanRun` against `contextUser`. Once the run user is elevated for a scoped anonymous
1233
- * caller (issue #3371) that check sees the SYSTEM user, so this is the only remaining place the
1234
- * caller's own authority is applied to a colleague. It therefore runs for EVERY caller, elevated
1235
- * or not — the authorization identity must never depend on the elevation decision.
1236
- *
1237
- * `HasPermission` reads AIEngineBase's in-memory caches (no DB round trip) and already fails
1238
- * closed on error, so an unresolvable agent drops OUT of the union rather than becoming runnable.
1239
- * A filtered-out colleague is not an error: the delegation layer reports it as "not available in
1240
- * this session" and lists what remains, which is the same answer the model gets for a typo.
1241
- *
1242
- * @param allowedAgents The session's persisted colleague union (absent/empty ⇒ single-target).
1243
- * @param contextUser The ORIGINAL caller — never the elevated run user.
1244
- * @returns The subset the caller may run, preserving order.
1245
- */
1246
- private async filterAllowedAgentsByCanRun(
1247
- allowedAgents: RealtimeAllowedAgent[] | undefined,
1248
- contextUser: UserInfo,
1249
- ): Promise<RealtimeAllowedAgent[] | undefined> {
1250
- if (!allowedAgents || allowedAgents.length === 0) {
1251
- return allowedAgents;
1252
- }
1253
- const verdicts = await Promise.all(
1254
- allowedAgents.map((a) => AIAgentPermissionHelper.HasPermission(a.agentId, contextUser, 'run')),
1255
- );
1256
- return allowedAgents.filter((_, i) => verdicts[i]);
1257
- }
1258
-
1259
1229
  /**
1260
1230
  * Resolves the AUTHORITATIVE target agent id under the co-agent's PAIRING CONSTRAINTS
1261
1231
  * (`MJ: AI Agent Co Agents`, ordered by `Sequence`):
@@ -1498,195 +1468,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1498
1468
  }
1499
1469
  }
1500
1470
 
1501
- /**
1502
- * Resolves the co-agent (the Realtime-type agent that voices the target agent) for a new
1503
- * client-direct session via the metadata-driven **CO-AGENT RESOLUTION CHAIN** — first match
1504
- * wins, evaluated in precedence order:
1505
- *
1506
- * 1. **Runtime parameter** — the mutation's explicit `coAgentId`. A per-call override; an
1507
- * invalid candidate (unknown, not Active, or not of the Realtime agent type) **throws**
1508
- * (fail loud — the caller asked for something specific, mirroring `preferredModelId`).
1509
- * 2. **Per-agent persona** — the target agent's `AIAgent.DefaultCoAgentID`. An invalid
1510
- * reference logs a warning and **falls through** to the next step (stale metadata should
1511
- * degrade gracefully, never break calls).
1512
- * 3. **Per-type default** — an Active `AIAgentCoAgent` row of Type `'CoAgent'` whose
1513
- * `TargetAgentTypeID` is the target agent's type and `IsDefault = 1` (lowest `Sequence`
1514
- * wins a tie). Same tolerant warn-and-fall-through semantics as step 2.
1515
- * 4. **Global default** — the seeded {@link REALTIME_CO_AGENT_NAME} agent, looked up by name
1516
- * (with a deprecated fallback to {@link LEGACY_REALTIME_CO_AGENT_NAME}). Throws when absent
1517
- * entirely (the realtime feature is unconfigured in this deployment).
1518
- *
1519
- * Every candidate from steps 1–3 is validated by {@link findValidCoAgent}: it must exist in
1520
- * {@link AIEngine}'s cached agents, have Status `Active`, and be of the
1521
- * {@link REALTIME_AGENT_TYPE_NAME} agent type. Step 4 keeps its original name-lookup contract.
1522
- *
1523
- * @param targetAgentId The agent the co-agent will voice on behalf of (drives steps 2–3).
1524
- * @param explicitCoAgentId The runtime `coAgentId` mutation argument, when supplied (step 1).
1525
- * @returns The resolved co-agent's id (canonical casing from the metadata cache).
1526
- */
1527
- private async resolveCoAgentID(
1528
- targetAgentId: string | undefined,
1529
- explicitCoAgentId: string | undefined,
1530
- contextUser: UserInfo,
1531
- provider: IMetadataProvider,
1532
- ): Promise<string> {
1533
- await AIEngine.Instance.Config(false, contextUser, provider);
1534
-
1535
- // Step 1 — explicit runtime parameter: fail LOUD on any problem.
1536
- if (explicitCoAgentId) {
1537
- const { agent, problem } = this.findValidCoAgent(explicitCoAgentId);
1538
- if (!agent) {
1539
- throw new Error(`Invalid coAgentId '${explicitCoAgentId}': ${problem}`);
1540
- }
1541
- return agent.ID;
1542
- }
1543
-
1544
- const targetAgent = targetAgentId
1545
- ? (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, targetAgentId))
1546
- : undefined;
1547
-
1548
- // Step 2 — the target agent's own DefaultCoAgentID (per-agent persona): warn + fall through.
1549
- const fromAgent = this.resolveMetadataDefault(
1550
- targetAgent?.DefaultCoAgentID,
1551
- `agent '${targetAgent?.Name}' (DefaultCoAgentID)`,
1552
- );
1553
- if (fromAgent) {
1554
- return fromAgent;
1555
- }
1556
-
1557
- // Step 3 — the type-level AIAgentCoAgent default row (per-type default): warn + fall through.
1558
- const agentType = targetAgent?.TypeID
1559
- ? (AIEngine.Instance.AgentTypes ?? []).find(t => UUIDsEqual(t.ID, targetAgent.TypeID))
1560
- : undefined;
1561
- const typeDefaultCoAgentID = agentType
1562
- ? this.findTypeDefaultCoAgentID(agentType.ID, contextUser, provider)
1563
- : undefined;
1564
- const fromType = this.resolveMetadataDefault(
1565
- await typeDefaultCoAgentID,
1566
- `agent type '${agentType?.Name}' (AIAgentCoAgent type-default row)`,
1567
- );
1568
- if (fromType) {
1569
- return fromType;
1570
- }
1571
-
1572
- // Step 4 — global default: the seeded Realtime Co-Agent, by name (original behavior).
1573
- return this.resolveGlobalCoAgentID();
1574
- }
1575
-
1576
- /**
1577
- * Chain step 3 lookup: the TYPE-LEVEL default co-agent for an agent type — the Active
1578
- * `AIAgentCoAgent` row of Type `'CoAgent'` whose `TargetAgentTypeID` matches, with
1579
- * `IsDefault = 1` preferred and the lowest `Sequence` breaking ties (so a deployment can
1580
- * stage multiple type-level candidates deterministically). Reads {@link AIEngineBase}'s
1581
- * cached rows — no RunView. Tolerant: a failed cache read logs and returns `undefined`
1582
- * (the chain falls through to the global default).
1583
- */
1584
- private async findTypeDefaultCoAgentID(
1585
- agentTypeID: string,
1586
- contextUser: UserInfo,
1587
- provider: IMetadataProvider,
1588
- ): Promise<string | undefined> {
1589
- try {
1590
- const engine = await this.configuredAIEngineBase(contextUser, provider);
1591
- const candidates = (engine.AgentCoAgents ?? [])
1592
- .filter((r) =>
1593
- r.Type === 'CoAgent' &&
1594
- r.Status === 'Active' &&
1595
- r.TargetAgentTypeID != null &&
1596
- UUIDsEqual(r.TargetAgentTypeID, agentTypeID))
1597
- .sort((a, b) =>
1598
- (a.IsDefault === b.IsDefault ? 0 : a.IsDefault ? -1 : 1) ||
1599
- (a.Sequence ?? 0) - (b.Sequence ?? 0));
1600
- return candidates[0]?.CoAgentID;
1601
- } catch (error) {
1602
- LogError(
1603
- `StartRealtimeClientSession: ${CO_AGENT_ENTITY} cache read failed while resolving the type-level ` +
1604
- `default co-agent for agent type ${agentTypeID} (${(error as Error).message}) — falling through.`,
1605
- );
1606
- return undefined;
1607
- }
1608
- }
1609
-
1610
- /**
1611
- * Validates one metadata-level co-agent default (chain steps 2/3). Returns the resolved
1612
- * co-agent id when the reference is valid; logs a warning and returns `null` (caller falls
1613
- * through to the next chain step) when the reference is set but invalid — metadata drift must
1614
- * degrade, not break live calls. A `null`/absent reference returns `null` silently.
1615
- */
1616
- private resolveMetadataDefault(candidateId: string | null | undefined, source: string): string | null {
1617
- if (!candidateId) {
1618
- return null;
1619
- }
1620
- const { agent, problem } = this.findValidCoAgent(candidateId);
1621
- if (agent) {
1622
- return agent.ID;
1623
- }
1624
- LogError(
1625
- `StartRealtimeClientSession: ignoring co-agent default '${candidateId}' from ${source} — ${problem} ` +
1626
- 'Falling through to the next step of the co-agent resolution chain.',
1627
- );
1628
- return null;
1629
- }
1630
-
1631
- /**
1632
- * Validates a co-agent candidate against {@link AIEngine}'s cached metadata. A valid co-agent
1633
- * must (a) exist, (b) have Status `Active`, and (c) be of the {@link REALTIME_AGENT_TYPE_NAME}
1634
- * agent type. Returns the cached agent on success, or a human-readable `problem` on failure —
1635
- * the CALLER decides whether that's fatal (explicit runtime param) or tolerated (metadata default).
1636
- */
1637
- private findValidCoAgent(candidateId: string): { agent?: MJAIAgentEntityExtended; problem?: string } {
1638
- const agent = (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, candidateId));
1639
- if (!agent) {
1640
- return { problem: 'no agent with that ID exists.' };
1641
- }
1642
- if (agent.Status !== 'Active') {
1643
- return { problem: `agent '${agent.Name}' is not Active (Status: ${agent.Status}).` };
1644
- }
1645
- const realtimeType = (AIEngine.Instance.AgentTypes ?? []).find(
1646
- t => t.Name?.trim().toLowerCase() === REALTIME_AGENT_TYPE_NAME.toLowerCase(),
1647
- );
1648
- if (!realtimeType) {
1649
- return { problem: `the '${REALTIME_AGENT_TYPE_NAME}' agent type is not configured in this deployment.` };
1650
- }
1651
- if (!agent.TypeID || !UUIDsEqual(agent.TypeID, realtimeType.ID)) {
1652
- return { problem: `agent '${agent.Name}' is not of the '${REALTIME_AGENT_TYPE_NAME}' agent type.` };
1653
- }
1654
- return { agent };
1655
- }
1656
-
1657
- /**
1658
- * Resolves the seeded Realtime Co-Agent's id from {@link AIEngine}'s cached agents — the GLOBAL
1659
- * DEFAULT (step 4) of the co-agent resolution chain. The engine is already configured by
1660
- * {@link resolveCoAgentID}. Looks up the current {@link REALTIME_CO_AGENT_NAME} first, then
1661
- * falls back to the DEPRECATED {@link LEGACY_REALTIME_CO_AGENT_NAME} (pre-rename seed) with a
1662
- * deprecation log so un-resynced deployments keep working. Throws a clear error when neither
1663
- * name is present in metadata.
1664
- */
1665
- private resolveGlobalCoAgentID(): string {
1666
- const coAgent = this.findAgentByName(REALTIME_CO_AGENT_NAME);
1667
- if (coAgent) {
1668
- return coAgent.ID;
1669
- }
1670
- const legacy = this.findAgentByName(LEGACY_REALTIME_CO_AGENT_NAME);
1671
- if (legacy) {
1672
- LogStatus(
1673
- `StartRealtimeClientSession: resolved the global co-agent via its DEPRECATED legacy name ` +
1674
- `'${LEGACY_REALTIME_CO_AGENT_NAME}'. Re-sync the agent seed metadata to rename it to ` +
1675
- `'${REALTIME_CO_AGENT_NAME}'.`,
1676
- );
1677
- return legacy.ID;
1678
- }
1679
- throw new Error(
1680
- `The '${REALTIME_CO_AGENT_NAME}' agent is not configured; cannot start a realtime voice session.`,
1681
- );
1682
- }
1683
-
1684
- /** Case/whitespace-insensitive agent lookup by Name in {@link AIEngine}'s cached agents. */
1685
- private findAgentByName(name: string): MJAIAgentEntityExtended | undefined {
1686
- const wanted = name.toLowerCase();
1687
- return (AIEngine.Instance.Agents ?? []).find(a => a.Name?.trim().toLowerCase() === wanted);
1688
- }
1689
-
1690
1471
  /**
1691
1472
  * Mints the client session config for a just-created session. On failure, closes the session so
1692
1473
  * no half-open record leaks, then throws the service's error message.
@@ -1812,7 +1593,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1812
1593
  const saved = await session.Save();
1813
1594
  if (!saved) {
1814
1595
  LogError(
1815
- `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
1596
+ `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
1816
1597
  );
1817
1598
  }
1818
1599
  }
@@ -1930,7 +1711,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1930
1711
  if (!saved) {
1931
1712
  LogError(
1932
1713
  `SaveSessionChannelState: save failed for session ${agentSessionID} / channel ${channelID}: ` +
1933
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
1714
+ `${this.describeSaveFailure(row)}`,
1934
1715
  );
1935
1716
  }
1936
1717
  return saved;
@@ -2313,7 +2094,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2313
2094
  artifact.UserID = contextUser.ID;
2314
2095
  artifact.Visibility = 'Always';
2315
2096
  if (!(await artifact.Save())) {
2316
- const message = `SaveSessionChannelArtifact: artifact save failed: ${artifact.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2097
+ const message = `SaveSessionChannelArtifact: artifact save failed for session ${session.ID}: ${this.describeSaveFailure(artifact)}`;
2317
2098
  LogError(message);
2318
2099
  return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
2319
2100
  }
@@ -2325,7 +2106,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2325
2106
  version.Content = contentJson;
2326
2107
  version.UserID = contextUser.ID;
2327
2108
  if (!(await version.Save())) {
2328
- const message = `SaveSessionChannelArtifact: artifact version save failed: ${version.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2109
+ const message = `SaveSessionChannelArtifact: artifact version save failed for session ${session.ID} (artifact ${artifact.ID}): ${this.describeSaveFailure(version)}`;
2329
2110
  LogError(message);
2330
2111
  return { Success: false, ErrorMessage: message, ArtifactID: artifact.ID, ConversationDetailLinked: false };
2331
2112
  }
@@ -2383,13 +2164,22 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2383
2164
  * queries (which scan junctions across ALL details) still surface the artifact. This is the
2384
2165
  * least-invasive correct anchor — no fake visible message, no orphaned artifact.
2385
2166
  *
2167
+ * TWO PRINCIPALS, deliberately: the lookup (`findLatestSessionDetailID`) and the junction row
2168
+ * itself (`saveDetailArtifactJunction`, `MJ: Conversation Detail Artifacts`) run as `runUser`
2169
+ * (elevated for a scoped-anonymous caller) — that junction entity is outside an anonymous
2170
+ * caller's relay grants. The hidden ANCHOR `Conversation Detail` this may need to create runs as
2171
+ * `callerUser` instead: like every other `MJ: Conversation Details` write, the System user is
2172
+ * refused because it is not the conversation's owner (see {@link persistDirectActionTurn},
2173
+ * issue #4791) — only the actual caller can create it.
2174
+ *
2386
2175
  * Strictly best-effort: every failure path logs and returns; a relayed tool call NEVER fails
2387
2176
  * because history linking did.
2388
2177
  */
2389
2178
  private async linkDelegatedArtifactsToConversation(
2390
2179
  session: MJAIAgentSessionEntity,
2391
2180
  artifacts: DelegatedRunArtifact[] | undefined,
2392
- contextUser: UserInfo,
2181
+ runUser: UserInfo,
2182
+ callerUser: UserInfo,
2393
2183
  provider: IMetadataProvider,
2394
2184
  ): Promise<void> {
2395
2185
  if (!artifacts || artifacts.length === 0 || !session.ConversationID) {
@@ -2397,13 +2187,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2397
2187
  }
2398
2188
  try {
2399
2189
  const detailID =
2400
- (await this.findLatestSessionDetailID(session, contextUser, provider)) ??
2401
- (await this.createHiddenSessionAnchorDetail(session, contextUser, provider));
2190
+ (await this.findLatestSessionDetailID(session, runUser, provider)) ??
2191
+ (await this.createHiddenSessionAnchorDetail(session, callerUser, provider));
2402
2192
  if (!detailID) {
2403
2193
  return; // anchor unavailable — logged in the helpers
2404
2194
  }
2405
2195
  for (const artifact of artifacts) {
2406
- await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, contextUser, provider);
2196
+ await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, runUser, provider);
2407
2197
  }
2408
2198
  } catch (error) {
2409
2199
  LogError(`ExecuteRealtimeSessionTool: delegated-artifact history link failed: ${(error as Error).message}`);
@@ -2444,25 +2234,25 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2444
2234
  */
2445
2235
  private async createHiddenSessionAnchorDetail(
2446
2236
  session: MJAIAgentSessionEntity,
2447
- contextUser: UserInfo,
2237
+ callerUser: UserInfo,
2448
2238
  provider: IMetadataProvider,
2449
2239
  ): Promise<string | null> {
2450
- const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, contextUser);
2240
+ const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, callerUser);
2451
2241
  detail.NewRecord();
2452
2242
  detail.ConversationID = session.ConversationID;
2453
2243
  detail.Role = 'AI';
2454
2244
  detail.HiddenToUser = true;
2455
2245
  detail.Message = 'Artifacts produced during a realtime session (system anchor).';
2456
2246
  detail.AgentSessionID = session.ID;
2457
- // Attribute the anchor to the SESSION owner, not the (possibly elevated) writer — identical
2458
- // for every non-elevated caller, whose ownership of the session is already proven.
2247
+ // Attribute to the SESSION owner — the caller writing this row, whose ownership
2248
+ // `loadOwnedActiveSession` already proved.
2459
2249
  detail.UserID = session.UserID;
2460
2250
  if (await detail.Save()) {
2461
2251
  return detail.ID;
2462
2252
  }
2463
2253
  LogError(
2464
2254
  `ExecuteRealtimeSessionTool: hidden anchor detail save failed for session ${session.ID}: ` +
2465
- `${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2255
+ `${this.describeSaveFailure(detail)}`,
2466
2256
  );
2467
2257
  return null;
2468
2258
  }
@@ -2489,7 +2279,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2489
2279
  if (!saved) {
2490
2280
  LogError(
2491
2281
  `RealtimeClientSessionResolver: artifact junction save failed for detail ${conversationDetailID} / version ${artifactVersionID}: ` +
2492
- `${junction.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2282
+ `${this.describeSaveFailure(junction)}`,
2493
2283
  );
2494
2284
  }
2495
2285
  return saved;
@@ -2529,7 +2319,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2529
2319
  if (!(await row.Save())) {
2530
2320
  LogError(
2531
2321
  `SaveSessionChannelArtifact: LastActiveAt stamp failed for session ${agentSessionID} / channel ${channelID}: ` +
2532
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2322
+ `${this.describeSaveFailure(row)}`,
2533
2323
  );
2534
2324
  }
2535
2325
  } catch (error) {
@@ -2673,12 +2463,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2673
2463
  return minutes && minutes > 0 ? minutes * 60 : undefined;
2674
2464
  }
2675
2465
 
2676
- /**
2677
- * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2678
- * conversation, the mapped role, the turn text, the session id, and the owning user.
2679
- *
2680
- * @returns The boolean save result (logs `CompleteMessage` on failure).
2681
- */
2682
2466
  /**
2683
2467
  * Stamps `RecordingStartedAt` (the recording `t0` alignment origin) + `RecordingMedia` on a
2684
2468
  * just-started session when the browser captured WITH consent. Best-effort: a parse/save failure
@@ -2705,11 +2489,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2705
2489
  session.RecordingMedia = 'Audio';
2706
2490
  if (!(await session.Save())) {
2707
2491
  LogError(
2708
- `RealtimeClientSessionResolver.stampRecordingStart save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2492
+ `RealtimeClientSessionResolver.stampRecordingStart save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
2709
2493
  );
2710
2494
  }
2711
2495
  }
2712
2496
 
2497
+ /**
2498
+ * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2499
+ * conversation, the mapped role, the turn text, the session id, and the owning user.
2500
+ *
2501
+ * @returns The boolean save result. On failure it logs the session, the write user and the
2502
+ * reason from {@link describeSaveFailure}.
2503
+ */
2713
2504
  private async persistTranscriptTurn(
2714
2505
  session: MJAIAgentSessionEntity,
2715
2506
  role: string,
@@ -2739,7 +2530,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2739
2530
  const saved = await detail.Save();
2740
2531
  if (!saved) {
2741
2532
  LogError(
2742
- `RealtimeClientSessionResolver.persistTranscriptTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2533
+ `RealtimeClientSessionResolver.persistTranscriptTurn save failed for session ${session.ID} (write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2743
2534
  );
2744
2535
  }
2745
2536
  return saved;
@@ -2780,8 +2571,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2780
2571
  // ⚠️ ELEVATING INSTEAD WAS TRIED AND IS A TRAP. `ResolveScopedAnonymousRunUser` returns
2781
2572
  // `UserCache.GetSystemUser()`, which on this deployment resolves to the unconfigured
2782
2573
  // placeholder `not.set@nowhere.com` ("Configured provisioning user not found; falling back
2783
- // to an Owner"). Saving as a user that does not exist returns false with a NULL
2784
- // `LatestResult` — indistinguishable from a permission denial, and just as silent.
2574
+ // to an Owner"). That user EXISTS — it is not a missing/invalid principal — and even carries
2575
+ // full CRUD on `MJ: Conversation Details`. It is refused anyway:
2576
+ // `MJConversationDetailEntityExtended`'s owner gate accepts only the conversation's OWNER or
2577
+ // an Edit/Owner Resource Permission grantee, and the System user is neither. Measured on
2578
+ // MJ#4791 (before the core-entities fix that now records the denial): `Save()` → `false`,
2579
+ // `LatestResult` null — indistinguishable from a permission denial, and just as silent. The
2580
+ // denial itself still happens; that fix only made it stop being silent.
2785
2581
  const writeUser = contextUser;
2786
2582
  const rv = RunView.FromMetadataProvider(provider);
2787
2583
  const result = await rv.RunView<MJConversationDetailEntity>(
@@ -2802,8 +2598,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2802
2598
  //
2803
2599
  // `ResultType: 'entity_object'` hands back hydrated entities, but they do not carry the
2804
2600
  // context user the way `GetEntityObject(entity, user)` does — so `Save()` ran with no
2805
- // principal and failed with an EMPTY message ("unknown error"), which is what the elevation
2806
- // fix above looked like when it was still broken. The insert path beside this one has
2601
+ // principal and failed with an EMPTY message ("unknown error"), which is what an empty-message
2602
+ // failure here looked like before the re-load. The insert path beside this one has
2807
2603
  // always used `GetEntityObject`; this now matches it, which is also why they now succeed
2808
2604
  // and fail for the same reasons.
2809
2605
  const previous = await provider.GetEntityObject<MJConversationDetailEntity>(
@@ -2835,7 +2631,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2835
2631
  `RealtimeClientSessionResolver.replacePreviousTranscriptTurn save failed for session `
2836
2632
  + `${session.ID} — the ${mappedRole} turn keeps its PREVIOUS, shorter text and the `
2837
2633
  + `transcript now understates what was said: `
2838
- + `${previous.LatestResult?.CompleteMessage || JSON.stringify(previous.LatestResult ?? null)}`,
2634
+ + `${this.describeSaveFailure(previous)}`,
2839
2635
  );
2840
2636
  }
2841
2637
  return saved;
@@ -2855,6 +2651,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2855
2651
  * Stored as stable facts (Role: 'AI', HiddenToUser: true, ExternalID: callId, CompletionTime: durationMs,
2856
2652
  * Message: JSON-serialized tool facts) so that session review can reconstruct the action card dynamically
2857
2653
  * on load (Option 1) without polluting user-facing speech chat bubbles.
2654
+ *
2655
+ * Writes as the CALLER, never the elevated run user (issue #4791). Owning the session
2656
+ * (`loadOwnedActiveSession`'s gate has already run on every path here) does NOT imply owning
2657
+ * the conversation: the conversation id was client-supplied at session start. What authorizes
2658
+ * this write is the save itself, evaluated for the caller — the conversation owner gate in
2659
+ * `MJConversationDetailEntityExtended`, plus the caller's own entity permission and Create RLS
2660
+ * on `MJ: Conversation Details` (for the magic-link role that RLS scopes by the invite's
2661
+ * `ScopeResourceID`, and the role belongs to a downstream app, not MJ core). That is why
2662
+ * writing as the caller is safe even though the conversation id originated client-side.
2663
+ * Elevating to the System user would bypass RLS entirely, and the owner gate refuses it anyway
2664
+ * because the System user is not the conversation owner. Measured on MJ#4791 (before the
2665
+ * core-entities fix that now records the denial): `Save()` returned `false` with a NULL `LatestResult` and an empty
2666
+ * `ResultHistory` — indistinguishable from any other silent failure. Elevating this write is
2667
+ * what silently dropped both hidden tool-execution turns of a real magic-link session; the
2668
+ * denial itself still happens today, only its silence was fixed separately.
2669
+ * `ResolveScopedAnonymousRunUser` elevates AI-**run**-entity writes only (prompt runs, agent
2670
+ * runs, the relayed dispatch itself) — never this one.
2858
2671
  */
2859
2672
  private async persistDirectActionTurn(
2860
2673
  session: MJAIAgentSessionEntity,
@@ -2870,10 +2683,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2870
2683
  return false;
2871
2684
  }
2872
2685
  try {
2873
- const writeUser = ResolveScopedAnonymousRunUser(contextUser);
2874
2686
  const detail = await provider.GetEntityObject<MJConversationDetailEntity>(
2875
2687
  CONVERSATION_DETAIL_ENTITY,
2876
- writeUser,
2688
+ contextUser,
2877
2689
  );
2878
2690
  detail.NewRecord();
2879
2691
  detail.ConversationID = session.ConversationID;
@@ -2915,13 +2727,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2915
2727
  const saved = await detail.Save();
2916
2728
  if (!saved) {
2917
2729
  LogError(
2918
- `RealtimeClientSessionResolver.persistDirectActionTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2730
+ `RealtimeClientSessionResolver.persistDirectActionTurn save failed for session ${session.ID} (tool '${toolName}', call ${callId ?? 'n/a'}, write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2919
2731
  );
2920
2732
  }
2921
2733
  return saved;
2922
2734
  } catch (error) {
2923
2735
  const message = error instanceof Error ? error.message : String(error);
2924
- LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error: ${message}`);
2736
+ LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error for session ${session.ID}: ${message}`);
2925
2737
  return false;
2926
2738
  }
2927
2739
  }