@memberjunction/server 5.40.2 → 5.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/agentSessions/HostInstance.d.ts +19 -0
  2. package/dist/agentSessions/HostInstance.d.ts.map +1 -0
  3. package/dist/agentSessions/HostInstance.js +48 -0
  4. package/dist/agentSessions/HostInstance.js.map +1 -0
  5. package/dist/agentSessions/SessionJanitor.d.ts +97 -0
  6. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -0
  7. package/dist/agentSessions/SessionJanitor.js +222 -0
  8. package/dist/agentSessions/SessionJanitor.js.map +1 -0
  9. package/dist/agentSessions/SessionManager.d.ts +142 -0
  10. package/dist/agentSessions/SessionManager.d.ts.map +1 -0
  11. package/dist/agentSessions/SessionManager.js +308 -0
  12. package/dist/agentSessions/SessionManager.js.map +1 -0
  13. package/dist/agentSessions/index.d.ts +4 -0
  14. package/dist/agentSessions/index.d.ts.map +1 -0
  15. package/dist/agentSessions/index.js +26 -0
  16. package/dist/agentSessions/index.js.map +1 -0
  17. package/dist/auth/initializeProviders.d.ts.map +1 -1
  18. package/dist/auth/initializeProviders.js +6 -1
  19. package/dist/auth/initializeProviders.js.map +1 -1
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +41 -7
  22. package/dist/context.js.map +1 -1
  23. package/dist/generated/generated.d.ts +659 -3
  24. package/dist/generated/generated.d.ts.map +1 -1
  25. package/dist/generated/generated.js +5459 -1759
  26. package/dist/generated/generated.js.map +1 -1
  27. package/dist/generic/ResolverBase.js +1 -1
  28. package/dist/generic/ResolverBase.js.map +1 -1
  29. package/dist/generic/RunViewResolver.js +9 -10
  30. package/dist/generic/RunViewResolver.js.map +1 -1
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +97 -41
  34. package/dist/index.js.map +1 -1
  35. package/dist/logging/StartupLogger.d.ts +121 -0
  36. package/dist/logging/StartupLogger.d.ts.map +1 -0
  37. package/dist/logging/StartupLogger.js +245 -0
  38. package/dist/logging/StartupLogger.js.map +1 -0
  39. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
  40. package/dist/logging/variablesLoggingMiddleware.js +21 -2
  41. package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
  42. package/dist/resolvers/AgentSessionResolver.d.ts +42 -0
  43. package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -0
  44. package/dist/resolvers/AgentSessionResolver.js +152 -0
  45. package/dist/resolvers/AgentSessionResolver.js.map +1 -0
  46. package/dist/resolvers/EntityPermissionResolver.d.ts +16 -0
  47. package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -0
  48. package/dist/resolvers/EntityPermissionResolver.js +95 -0
  49. package/dist/resolvers/EntityPermissionResolver.js.map +1 -0
  50. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +688 -0
  51. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -0
  52. package/dist/resolvers/RealtimeClientSessionResolver.js +1774 -0
  53. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -0
  54. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +359 -0
  55. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -0
  56. package/dist/resolvers/RemoteBrowserActionResolver.js +896 -0
  57. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -0
  58. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  59. package/dist/services/ScheduledJobsService.js +6 -5
  60. package/dist/services/ScheduledJobsService.js.map +1 -1
  61. package/package.json +78 -74
  62. package/src/__tests__/RealtimeClientSessionResolver.test.ts +2605 -0
  63. package/src/__tests__/RemoteBrowserAudioStream.test.ts +174 -0
  64. package/src/__tests__/SessionJanitor.test.ts +234 -0
  65. package/src/__tests__/SessionManager.test.ts +465 -0
  66. package/src/__tests__/subscriptionRedaction.test.ts +5 -0
  67. package/src/agentSessions/HostInstance.ts +53 -0
  68. package/src/agentSessions/SessionJanitor.ts +267 -0
  69. package/src/agentSessions/SessionManager.ts +446 -0
  70. package/src/agentSessions/index.ts +27 -0
  71. package/src/auth/initializeProviders.ts +6 -1
  72. package/src/context.ts +42 -7
  73. package/src/generated/generated.ts +3111 -567
  74. package/src/generic/ResolverBase.ts +1 -1
  75. package/src/generic/RunViewResolver.ts +9 -9
  76. package/src/index.ts +98 -41
  77. package/src/logging/StartupLogger.ts +317 -0
  78. package/src/logging/variablesLoggingMiddleware.ts +25 -5
  79. package/src/resolvers/AgentSessionResolver.ts +138 -0
  80. package/src/resolvers/EntityPermissionResolver.ts +73 -0
  81. package/src/resolvers/RealtimeClientSessionResolver.ts +2162 -0
  82. package/src/resolvers/RemoteBrowserActionResolver.ts +907 -0
  83. package/src/services/ScheduledJobsService.ts +6 -5
@@ -0,0 +1,1774 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ /**
14
+ * @fileoverview GraphQL resolvers for the CLIENT-DIRECT realtime voice topology (P5b-ii).
15
+ *
16
+ * These resolvers are **thin**: they wire two already-built pieces together —
17
+ * {@link RealtimeClientSessionService} (in `@memberjunction/ai-agents`, the server-side half of the
18
+ * client-direct contract) and {@link SessionManager} (the durable `AIAgentSession` record manager) —
19
+ * over a request's `contextUser` + request-scoped `IMetadataProvider`.
20
+ *
21
+ * In the client-direct topology the browser opens its OWN provider socket (e.g. WebRTC) using a
22
+ * server-minted ephemeral token, but the server retains authority over the system prompt + tool set
23
+ * and **executes** every tool call the browser relays back. The three mutations here cover the MVP:
24
+ *
25
+ * 1. {@link RealtimeClientSessionResolver.StartRealtimeClientSession} — authorize, create the
26
+ * session (storing the target agent id server-side, authoritatively), mint the ephemeral config.
27
+ * 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call,
28
+ * reading the target agent id from the session (NOT the client) so the browser can't swap targets.
29
+ * 3. {@link RealtimeClientSessionResolver.RelayRealtimeTranscript} — persist a transcript turn as a
30
+ * `Conversation Detail`.
31
+ *
32
+ * Authorization model:
33
+ * - **Start**: the caller must have `CanRun` on the *target* agent (the Realtime Co-Agent is an internal
34
+ * orchestration agent, so the meaningful gate is the agent doing the real work).
35
+ * - **Execute / Relay**: inbound ownership — the session's `UserID` must equal `contextUser.ID`.
36
+ *
37
+ * @module @memberjunction/server
38
+ */
39
+ import { Resolver, Mutation, Arg, Ctx, Int, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
40
+ import { AuthorizationEvaluator, LogError, LogStatus, RunView } from '@memberjunction/core';
41
+ import { UUIDsEqual } from '@memberjunction/global';
42
+ import { AIEngine } from '@memberjunction/aiengine';
43
+ import { AIAgentPermissionHelper, AIEngineBase } from '@memberjunction/ai-engine-base';
44
+ import { RealtimeClientSessionService, RealtimeChannelServerHost, EvaluateRuntimeOverrideAuthorization, ParseRealtimeTypeConfiguration, ResolveEffectiveRealtimeConfig, REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION, } from '@memberjunction/ai-agents';
45
+ import { ResolverBase } from '../generic/ResolverBase.js';
46
+ import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
47
+ import { GetReadWriteProvider } from '../util.js';
48
+ import { SessionManager } from '../agentSessions/index.js';
49
+ /**
50
+ * Progress steps worth narrating to the realtime model — mirrors the normal agent-run path's filter
51
+ * in {@link import('./RunAIAgentResolver.js').RunAIAgentResolver}. Initialization/validation/finalization
52
+ * noise is dropped so the model only narrates meaningful work.
53
+ */
54
+ const SIGNIFICANT_PROGRESS_STEPS = ['prompt_execution', 'action_execution', 'subagent_execution', 'decision_processing'];
55
+ /**
56
+ * The seeded name of the internal orchestration agent that fronts a target agent in realtime
57
+ * sessions (voice + interactive channels). This is the GLOBAL DEFAULT co-agent — the final step of
58
+ * the co-agent resolution chain (see {@link RealtimeClientSessionResolver.resolveCoAgentID}).
59
+ * Deployments can override it per agent (`AIAgent.DefaultCoAgentID`), per agent type (an
60
+ * `AIAgentCoAgent` row with `TargetAgentTypeID` + `IsDefault`), or per call (the `coAgentId`
61
+ * mutation argument) without
62
+ * touching this seed.
63
+ */
64
+ const REALTIME_CO_AGENT_NAME = 'Realtime Co-Agent';
65
+ /**
66
+ * DEPRECATED legacy seed name of {@link REALTIME_CO_AGENT_NAME}, from before the agent's rename
67
+ * from "Voice Co-Agent" to "Realtime Co-Agent". Deployments that have not re-synced the agent seed
68
+ * still carry this name, so {@link RealtimeClientSessionResolver.resolveGlobalCoAgentID} falls
69
+ * back to it (with a deprecation log) when no agent named {@link REALTIME_CO_AGENT_NAME} exists.
70
+ */
71
+ const LEGACY_REALTIME_CO_AGENT_NAME = 'Voice Co-Agent';
72
+ /**
73
+ * The seeded name of the Realtime agent TYPE. Every co-agent candidate (explicit or metadata
74
+ * default) must be an Active agent of this type — a co-agent drives a streaming full-duplex
75
+ * realtime session, which only `RealtimeAgentType`-driven agents support.
76
+ */
77
+ const REALTIME_AGENT_TYPE_NAME = 'Realtime';
78
+ /** Entity name — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
79
+ const SESSION_ENTITY = 'MJ: AI Agent Sessions';
80
+ const CO_AGENT_ENTITY = 'MJ: AI Agent Co Agents';
81
+ const CONVERSATION_DETAIL_ENTITY = 'MJ: Conversation Details';
82
+ const CHANNEL_ENTITY = 'MJ: AI Agent Channels';
83
+ const SESSION_CHANNEL_ENTITY = 'MJ: AI Agent Session Channels';
84
+ const ARTIFACT_ENTITY = 'MJ: Artifacts';
85
+ const ARTIFACT_VERSION_ENTITY = 'MJ: Artifact Versions';
86
+ const ARTIFACT_TYPE_ENTITY = 'MJ: Artifact Types';
87
+ const CONVERSATION_DETAIL_ARTIFACT_ENTITY = 'MJ: Conversation Detail Artifacts';
88
+ /**
89
+ * The seeded name of the artifact type that {@link RealtimeClientSessionResolver.SaveSessionChannelArtifact}
90
+ * stamps onto saved channel-state artifacts (e.g. the live whiteboard's board JSON). Seeded in
91
+ * `metadata/artifact-types/` — its absence is a graceful structured failure, never a throw.
92
+ */
93
+ const WHITEBOARD_ARTIFACT_TYPE_NAME = 'Whiteboard';
94
+ /**
95
+ * Maximum number of client-declared UI tools accepted at session mint. Sized to comfortably fit
96
+ * MULTIPLE interactive channels at once plus headroom: the Whiteboard channel alone declares 17 tools
97
+ * and the Remote Browser channel 10, so a session with both is ~27 — the old cap of 16 silently rejected
98
+ * the ENTIRE set (parseClientTools is all-or-nothing), leaving the co-agent with only invoke-target-agent
99
+ * and forcing it to delegate every channel request. This is an abuse ceiling, not a working limit.
100
+ */
101
+ const MAX_CLIENT_TOOLS = 64;
102
+ /** Maximum accepted size (chars) of the serialized client tool declarations. Raised in step with
103
+ * {@link MAX_CLIENT_TOOLS} — multi-channel tool sets with verbose descriptions + JSON schemas run large. */
104
+ const MAX_CLIENT_TOOLS_JSON_CHARS = 256_000;
105
+ /** Maximum accepted size (chars) of a persisted channel state blob. */
106
+ const MAX_CHANNEL_STATE_CHARS = 2_000_000;
107
+ /** Maximum prior-leg transcript TURNS hydrated into a resumed session's system prompt (newest kept). */
108
+ const MAX_PRIOR_TRANSCRIPT_TURNS = 30;
109
+ /** Maximum prior-leg transcript CHARS hydrated into a resumed session's system prompt (oldest dropped). */
110
+ const MAX_PRIOR_TRANSCRIPT_CHARS = 8_000;
111
+ /** Maximum prior-session chain legs walked when hydrating a resumed session's transcript. */
112
+ const MAX_PRIOR_TRANSCRIPT_LEGS = 5;
113
+ /**
114
+ * Result of {@link RealtimeClientSessionResolver.StartRealtimeClientSession} — everything the
115
+ * browser needs to open its own provider socket plus the durable session linkage.
116
+ */
117
+ let StartRealtimeClientSessionResult = class StartRealtimeClientSessionResult {
118
+ };
119
+ __decorate([
120
+ Field(() => String),
121
+ __metadata("design:type", String)
122
+ ], StartRealtimeClientSessionResult.prototype, "AgentSessionId", void 0);
123
+ __decorate([
124
+ Field(() => String),
125
+ __metadata("design:type", String)
126
+ ], StartRealtimeClientSessionResult.prototype, "ConversationId", void 0);
127
+ __decorate([
128
+ Field(() => String),
129
+ __metadata("design:type", String)
130
+ ], StartRealtimeClientSessionResult.prototype, "Provider", void 0);
131
+ __decorate([
132
+ Field(() => String),
133
+ __metadata("design:type", String)
134
+ ], StartRealtimeClientSessionResult.prototype, "Model", void 0);
135
+ __decorate([
136
+ Field(() => String),
137
+ __metadata("design:type", String)
138
+ ], StartRealtimeClientSessionResult.prototype, "EphemeralToken", void 0);
139
+ __decorate([
140
+ Field(() => String),
141
+ __metadata("design:type", String)
142
+ ], StartRealtimeClientSessionResult.prototype, "ExpiresAt", void 0);
143
+ __decorate([
144
+ Field(() => String),
145
+ __metadata("design:type", String)
146
+ ], StartRealtimeClientSessionResult.prototype, "SessionConfigJson", void 0);
147
+ __decorate([
148
+ Field(() => String, { nullable: true }),
149
+ __metadata("design:type", String)
150
+ ], StartRealtimeClientSessionResult.prototype, "ModelName", void 0);
151
+ __decorate([
152
+ Field(() => String, { nullable: true }),
153
+ __metadata("design:type", String)
154
+ ], StartRealtimeClientSessionResult.prototype, "NarrationInstructionsTemplate", void 0);
155
+ __decorate([
156
+ Field(() => String, { nullable: true }),
157
+ __metadata("design:type", String)
158
+ ], StartRealtimeClientSessionResult.prototype, "PriorChannelStatesJson", void 0);
159
+ __decorate([
160
+ Field(() => Int, { nullable: true }),
161
+ __metadata("design:type", Number)
162
+ ], StartRealtimeClientSessionResult.prototype, "NarrationPaceMs", void 0);
163
+ __decorate([
164
+ Field(() => String, { nullable: true }),
165
+ __metadata("design:type", String)
166
+ ], StartRealtimeClientSessionResult.prototype, "EffectiveConfigJson", void 0);
167
+ StartRealtimeClientSessionResult = __decorate([
168
+ ObjectType()
169
+ ], StartRealtimeClientSessionResult);
170
+ export { StartRealtimeClientSessionResult };
171
+ /**
172
+ * Result of {@link RealtimeClientSessionResolver.SaveSessionChannelArtifact} — a structured
173
+ * success/failure envelope (graceful failures like a missing artifact type must not throw).
174
+ */
175
+ let SaveSessionChannelArtifactResult = class SaveSessionChannelArtifactResult {
176
+ };
177
+ __decorate([
178
+ Field(() => Boolean),
179
+ __metadata("design:type", Boolean)
180
+ ], SaveSessionChannelArtifactResult.prototype, "Success", void 0);
181
+ __decorate([
182
+ Field(() => String, { nullable: true }),
183
+ __metadata("design:type", String)
184
+ ], SaveSessionChannelArtifactResult.prototype, "ErrorMessage", void 0);
185
+ __decorate([
186
+ Field(() => String, { nullable: true }),
187
+ __metadata("design:type", String)
188
+ ], SaveSessionChannelArtifactResult.prototype, "ArtifactID", void 0);
189
+ __decorate([
190
+ Field(() => String, { nullable: true }),
191
+ __metadata("design:type", String)
192
+ ], SaveSessionChannelArtifactResult.prototype, "ArtifactVersionID", void 0);
193
+ __decorate([
194
+ Field(() => Boolean),
195
+ __metadata("design:type", Boolean)
196
+ ], SaveSessionChannelArtifactResult.prototype, "ConversationDetailLinked", void 0);
197
+ SaveSessionChannelArtifactResult = __decorate([
198
+ ObjectType()
199
+ ], SaveSessionChannelArtifactResult);
200
+ export { SaveSessionChannelArtifactResult };
201
+ /**
202
+ * Result of {@link RealtimeClientSessionResolver.CancelRealtimeSessionTool} — a structured
203
+ * success/failure envelope, mirroring {@link SaveSessionChannelArtifactResult}: tolerated
204
+ * problems (an unexpected registry error) come back as `Success: false`, never a throw, while
205
+ * authn/ownership violations still throw like the sibling mutations.
206
+ */
207
+ let CancelRealtimeSessionToolResult = class CancelRealtimeSessionToolResult {
208
+ };
209
+ __decorate([
210
+ Field(() => Int),
211
+ __metadata("design:type", Number)
212
+ ], CancelRealtimeSessionToolResult.prototype, "AbortedCount", void 0);
213
+ __decorate([
214
+ Field(() => Boolean),
215
+ __metadata("design:type", Boolean)
216
+ ], CancelRealtimeSessionToolResult.prototype, "Success", void 0);
217
+ __decorate([
218
+ Field(() => String, { nullable: true }),
219
+ __metadata("design:type", String)
220
+ ], CancelRealtimeSessionToolResult.prototype, "ErrorMessage", void 0);
221
+ CancelRealtimeSessionToolResult = __decorate([
222
+ ObjectType()
223
+ ], CancelRealtimeSessionToolResult);
224
+ export { CancelRealtimeSessionToolResult };
225
+ /**
226
+ * Resolver for the client-direct realtime voice topology. A single {@link SessionManager} and a
227
+ * single {@link RealtimeClientSessionService} are shared across requests — neither holds per-user or
228
+ * per-provider state; every method is passed the request `contextUser` + provider explicitly.
229
+ */
230
+ let RealtimeClientSessionResolver = class RealtimeClientSessionResolver extends ResolverBase {
231
+ constructor() {
232
+ super(...arguments);
233
+ this.sessionManager = new SessionManager();
234
+ this.clientSessionService = new RealtimeClientSessionService();
235
+ }
236
+ /**
237
+ * Start a client-direct realtime voice session targeting `targetAgentId`.
238
+ *
239
+ * Flow:
240
+ * 1. Authorize — the caller must have `CanRun` on the **target** agent; denial throws and no
241
+ * session is created.
242
+ * 2. Resolve the co-agent id via the metadata-driven resolution chain (runtime `coAgentId` →
243
+ * agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
244
+ * see {@link RealtimeClientSessionResolver.resolveCoAgentID} for the full contract.
245
+ * 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
246
+ * config server-side — this is the authoritative target for all later relays.
247
+ * 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
248
+ * On failure the just-created session is closed so no half-open session leaks.
249
+ *
250
+ * **SECURITY NOTE — `clientToolsJson`:** these are CLIENT-EXECUTED UI tools (e.g. the live
251
+ * whiteboard's `Whiteboard.*` surface). The server only *declares* them to the realtime model
252
+ * so it can call them — the server NEVER executes them, and a relayed call for one of these
253
+ * names falls into the standard "not available" path on the server side. They grant no
254
+ * server-side capability whatsoever; server-executed tools remain exclusively server-declared
255
+ * (`invoke-target-agent` + future action wiring). The declarations are still validated
256
+ * (count cap, size cap, per-tool shape) so a hostile client can't bloat the session config.
257
+ *
258
+ * @param targetAgentId The agent the co-agent voices. OPTIONAL when the resolved co-agent
259
+ * has pairing rows with an `IsDefault` target (`MJ: AI Agent Co Agents`) — the default
260
+ * pairing stands in. Required for a universal co-agent (zero pairing rows). When the
261
+ * co-agent HAS pairing rows, a supplied target must be in that list (clear error otherwise).
262
+ * @param coAgentId Optional EXPLICIT co-agent choice (`MJ: AI Agents.ID` of an Active,
263
+ * Realtime-type agent). When set, the server uses exactly that co-agent and FAILS with a
264
+ * clear reason if it can't (no silent fallback — mirroring `preferredModelId`'s contract).
265
+ * Omit to let metadata drive the choice: the target agent's `DefaultCoAgentID`, then the
266
+ * type-level `AIAgentCoAgent` default row for its agent type, then the global Realtime Co-Agent.
267
+ * @param configOverridesJson Optional RUNTIME configuration-override layer (the most-specific
268
+ * layer of the effective-config merge: type `DefaultConfiguration` ← agent
269
+ * `TypeConfiguration` ← this). **Authorization-gated**: requires the
270
+ * `Realtime: Advanced Session Controls` authorization — unauthorized callers receive a
271
+ * structured rejection (never a silent ignore). Must be a JSON object.
272
+ *
273
+ * @returns The ephemeral config + session linkage the browser needs to open its socket.
274
+ */
275
+ async StartRealtimeClientSession(targetAgentId, { userPayload, providers }, conversationId, lastSessionId, preferredModelId, clientToolsJson, coAgentId, configOverridesJson) {
276
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
277
+ const coAgentID = await this.resolveCoAgentID(targetAgentId, coAgentId, contextUser, provider);
278
+ // PAIRING CONSTRAINTS: a co-agent with pairing rows is restricted to that target list
279
+ // (with the IsDefault row standing in when no runtime target was supplied); zero rows =
280
+ // universal, today's behavior untouched. Resolves the AUTHORITATIVE target id.
281
+ const effectiveTargetId = await this.resolveConstrainedTargetAgentID(coAgentID, targetAgentId, contextUser, provider);
282
+ await this.assertCanRunTarget(effectiveTargetId, contextUser, provider);
283
+ // RUNTIME-OVERRIDE GATE: configOverridesJson and a DEVIATING explicit model both require
284
+ // the 'Realtime: Advanced Session Controls' authorization (structured rejection, never a
285
+ // silent ignore). Plain starts and within-pairing target selection are never gated here.
286
+ await this.assertRuntimeOverridesAuthorized(coAgentID, configOverridesJson, preferredModelId, contextUser, provider);
287
+ const config = { targetAgentID: effectiveTargetId };
288
+ const session = await this.sessionManager.CreateSession({
289
+ agentID: coAgentID,
290
+ userID: contextUser.ID,
291
+ conversationID: conversationId,
292
+ lastSessionID: lastSessionId,
293
+ config: JSON.stringify(config),
294
+ }, contextUser, provider);
295
+ const clientTools = this.parseClientTools(clientToolsJson);
296
+ // Best-effort model-context hydration: the PRIOR session chain's transcript (ownership-
297
+ // checked, capped) is framed into the system prompt so the model REMEMBERS the last leg.
298
+ // Strictly tolerant — any problem yields no hydration, never a failed start.
299
+ const priorTranscript = await this.loadPriorTranscript(lastSessionId, contextUser, provider);
300
+ const result = await this.prepareClientSessionOrClose(session, coAgentID, effectiveTargetId, contextUser, provider, preferredModelId, clientTools, priorTranscript, configOverridesJson);
301
+ // Best-effort restore of the PRIOR session's persisted channel states (e.g. the whiteboard
302
+ // board). Strictly tolerant — any problem yields a null field, never a failed start.
303
+ result.PriorChannelStatesJson = (await this.loadPriorChannelStatesJson(lastSessionId, contextUser, provider)) ?? undefined;
304
+ return result;
305
+ }
306
+ /**
307
+ * Execute a single tool call the browser relayed from its provider socket and return the
308
+ * serialized result for the browser to relay back to the model.
309
+ *
310
+ * Ownership-gated; the target agent id is read from the **session config** (authoritative), never
311
+ * from the client. Heartbeats the session on success.
312
+ *
313
+ * @returns The serialized tool result JSON.
314
+ */
315
+ async ExecuteRealtimeSessionTool(agentSessionId, callId, toolName, argsJson, { userPayload, providers }, pubSub) {
316
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
317
+ const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
318
+ const config = this.readSessionConfig(session);
319
+ const { ResultJson, PausedRunID, Artifacts } = await this.clientSessionService.ExecuteRelayedTool({
320
+ AgentSessionID: agentSessionId,
321
+ TargetAgentID: config.targetAgentID,
322
+ // Nest the delegated target-agent run under the co-agent observability run (when present).
323
+ ParentRunID: config.coAgentRunID,
324
+ Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
325
+ OnProgress: this.buildDelegationProgressCallback(pubSub, userPayload, agentSessionId, callId),
326
+ // Resume a previously-paused delegated run (if any) with the user's answer.
327
+ ResumeRunID: config.pendingFeedbackRunID,
328
+ }, contextUser, provider);
329
+ // Roll the paused-run id forward in the session config: clear the one we just consumed, and
330
+ // store a new one only if the (resumed or fresh) run paused again awaiting feedback.
331
+ await this.updatePendingFeedbackRunID(session, config, PausedRunID);
332
+ // Junction-link any artifacts the delegated run produced into the session's conversation
333
+ // history (best-effort) — so chat, session review, and resume carryover can all see them.
334
+ await this.linkDelegatedArtifactsToConversation(session, Artifacts, contextUser, provider);
335
+ await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
336
+ return ResultJson;
337
+ }
338
+ /**
339
+ * Builds the `OnProgress` callback threaded into the delegated target-agent run. Each significant
340
+ * progress event (see {@link SIGNIFICANT_PROGRESS_STEPS}) is published to
341
+ * {@link PUSH_STATUS_UPDATES_TOPIC} on this user's `sessionId` so the browser can correlate it to
342
+ * THIS voice session (via `agentSessionID` + `callID`) and feed it to the realtime model to
343
+ * narrate — distinct from a normal `RunAIAgentResolver` agent-run stream by `resolver`/`type`.
344
+ */
345
+ buildDelegationProgressCallback(pubSub, userPayload, agentSessionID, callID) {
346
+ return (progress) => {
347
+ if (!SIGNIFICANT_PROGRESS_STEPS.includes(progress.step)) {
348
+ return;
349
+ }
350
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
351
+ message: JSON.stringify({
352
+ resolver: 'RealtimeClientSessionResolver',
353
+ type: 'RealtimeDelegationProgress',
354
+ agentSessionID,
355
+ callID,
356
+ step: progress.step,
357
+ message: progress.message,
358
+ percentage: progress.percentage,
359
+ }),
360
+ sessionId: userPayload.sessionId,
361
+ });
362
+ };
363
+ }
364
+ /**
365
+ * Rolls the session's `pendingFeedbackRunID` forward after a relayed tool call. The id we just
366
+ * consumed (resumed or none) is dropped; a new paused run id is stored only when the run paused
367
+ * again awaiting feedback. When neither the old nor new value is set, this is a no-op (no save).
368
+ * Best-effort: a save failure is logged, not thrown.
369
+ */
370
+ async updatePendingFeedbackRunID(session, config, pausedRunID) {
371
+ const previous = config.pendingFeedbackRunID;
372
+ if (!previous && !pausedRunID) {
373
+ return;
374
+ }
375
+ const next = {
376
+ targetAgentID: config.targetAgentID,
377
+ coAgentRunID: config.coAgentRunID,
378
+ promptRunID: config.promptRunID,
379
+ coAgentRunStepID: config.coAgentRunStepID,
380
+ pendingFeedbackRunID: pausedRunID,
381
+ };
382
+ session.Config_ = JSON.stringify(next);
383
+ if (!(await session.Save())) {
384
+ LogError(`RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
385
+ }
386
+ }
387
+ /**
388
+ * Persist a transcript turn (user or assistant) as a `Conversation Detail` on the session's
389
+ * conversation. Ownership-gated; heartbeats the session on success.
390
+ *
391
+ * The co-agent observability `AIAgentRun`/`AIPromptRun` are created at session start (see
392
+ * {@link RealtimeClientSessionResolver.StartRealtimeClientSession}) and finalized on close;
393
+ * incremental usage telemetry lands on the `AIPromptRun` via
394
+ * {@link RealtimeClientSessionResolver.RelayRealtimeUsage}. Each persisted turn is ALSO appended
395
+ * to the co-agent `AIPromptRun.Messages` (best-effort), so the run captures the full conversation
396
+ * — observability parity with every other MJ agent run, not just token totals.
397
+ *
398
+ * @returns `true` when the transcript turn was persisted.
399
+ */
400
+ async RelayRealtimeTranscript(agentSessionId, role, text, { userPayload, providers }, replacesPrevious) {
401
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
402
+ const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
403
+ const saved = replacesPrevious
404
+ ? await this.replacePreviousTranscriptTurn(session, role, text, contextUser, provider)
405
+ : await this.persistTranscriptTurn(session, role, text, contextUser, provider);
406
+ if (!saved) {
407
+ return false;
408
+ }
409
+ // Mirror the turn onto the co-agent's long-lived prompt run so its Messages capture the full
410
+ // conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
411
+ const promptRunID = this.readPromptRunID(session);
412
+ if (promptRunID) {
413
+ await this.clientSessionService.AppendPromptRunMessage(promptRunID, this.mapTranscriptRoleToChatRole(role), text, replacesPrevious ?? false, contextUser, provider);
414
+ }
415
+ await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
416
+ return true;
417
+ }
418
+ /**
419
+ * Maps the relayed transcript role (`'User'`/`'AI'`/`'Assistant'`, case-insensitive) to the
420
+ * standard chat-message role stored in `AIPromptRun.Messages`. Anything that isn't an explicit
421
+ * user turn is treated as the assistant (the co-agent's own speech).
422
+ */
423
+ mapTranscriptRoleToChatRole(role) {
424
+ return role.trim().toLowerCase() === 'user' ? 'user' : 'assistant';
425
+ }
426
+ /**
427
+ * Relays a co-agent CHANNEL tool-call (browser_ / Whiteboard_ etc.) onto the session's co-agent
428
+ * AIPromptRun.Messages — run-only observability so the run captures what the co-agent DID, not just
429
+ * what it said. Deliberately NOT a ConversationDetail turn (the chat thread stays speech-only).
430
+ * Ownership-gated; best-effort (a missing prompt run / save failure simply returns false).
431
+ *
432
+ * @returns `true` when the tool turn was recorded on the run.
433
+ */
434
+ async RelayRealtimeToolTurn(agentSessionId, toolName, { userPayload, providers }, argsJson, resultJson) {
435
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
436
+ const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
437
+ const promptRunID = this.readPromptRunID(session);
438
+ if (!promptRunID) {
439
+ return false;
440
+ }
441
+ return this.clientSessionService.AppendPromptRunMessage(promptRunID, 'assistant', this.formatToolTurn(toolName, argsJson, resultJson), false, contextUser, provider);
442
+ }
443
+ /**
444
+ * Formats a co-agent tool call into a compact, human-readable run line: `🔧 <tool> <args> → <result>`,
445
+ * clipping args/result so a verbose payload never bloats the Messages blob.
446
+ */
447
+ formatToolTurn(toolName, argsJson, resultJson) {
448
+ const clip = (raw, max) => {
449
+ const t = (raw ?? '').trim();
450
+ return t.length > max ? `${t.slice(0, max)}…` : t;
451
+ };
452
+ const args = clip(argsJson, 200);
453
+ const result = clip(resultJson, 300);
454
+ return `🔧 ${toolName}${args ? ` ${args}` : ''}${result ? ` → ${result}` : ''}`;
455
+ }
456
+ /**
457
+ * Cancel in-flight relayed tool delegations for a session — the CLIENT-DIRECT cancel channel.
458
+ *
459
+ * The browser calls this on an EXPLICIT user cancel (the call overlay's per-card ✕ — a
460
+ * deliberate host policy: true barge-in alone never aborts delegations, since the narration
461
+ * design expects the user to keep talking while delegated work runs). The service-side
462
+ * in-flight registry aborts the matching `AbortController`(s), which fires the delegated
463
+ * run's `cancellationToken`; the original `ExecuteRealtimeSessionTool` mutation then resolves
464
+ * with the run's cancelled/failed result through its normal path.
465
+ *
466
+ * Ownership-gated like the sibling mutations. A **Closed** session is accepted — a cancel can
467
+ * legitimately race teardown, and aborting stragglers is exactly what the caller wants then.
468
+ *
469
+ * @param callId When supplied, only the delegation for that provider call id is aborted;
470
+ * omit to abort ALL in-flight delegations for the session.
471
+ * @returns A structured {@link CancelRealtimeSessionToolResult}. Tolerant: an unknown call id
472
+ * or a session with nothing in flight is `Success: true, AbortedCount: 0` (the work may
473
+ * simply have finished already); an unexpected registry error is a structured failure.
474
+ */
475
+ async CancelRealtimeSessionTool(agentSessionId, { userPayload, providers }, callId) {
476
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
477
+ await this.loadOwnedSession(agentSessionId, contextUser, provider);
478
+ try {
479
+ const aborted = this.clientSessionService.CancelInFlightDelegations(agentSessionId, callId);
480
+ return { AbortedCount: aborted, Success: true };
481
+ }
482
+ catch (error) {
483
+ const message = `CancelRealtimeSessionTool failed for session ${agentSessionId}: ${error.message}`;
484
+ LogError(message);
485
+ return { AbortedCount: 0, Success: false, ErrorMessage: message };
486
+ }
487
+ }
488
+ /**
489
+ * Relay incremental usage telemetry from the browser's provider socket onto the co-agent's
490
+ * observability `AIPromptRun` (the run created at session start).
491
+ *
492
+ * The browser accumulates the realtime client's `OnUsage` token DELTAS and flushes them here
493
+ * debounced (~10s) plus once at teardown; this mutation ACCUMULATES the deltas onto the
494
+ * prompt run's `TokensPrompt` / `TokensCompletion` (and recomputes `TokensUsed`). Status is
495
+ * deliberately untouched — `FinalizeCoAgentRun` keeps stamping Success/Completed at close,
496
+ * and a post-close flush still lands (accumulation has no Status gate).
497
+ *
498
+ * Ownership-gated like the sibling mutations; a **Closed** session is accepted (the final
499
+ * teardown flush can land after `CloseAgentSession`). Everything PAST the ownership gate is
500
+ * best-effort and tolerant: a missing/malformed session config, an absent `promptRunID`
501
+ * (observability creation was skipped), a failed load, or a failed save all log and return
502
+ * `false` — usage relay must never break a live call.
503
+ *
504
+ * @param inputTokens Input-token DELTA to add (negative/non-finite values are clamped to 0).
505
+ * @param outputTokens Output-token DELTA to add (negative/non-finite values are clamped to 0).
506
+ * @returns `true` when the accumulated usage was persisted; `false` on any tolerated failure.
507
+ */
508
+ async RelayRealtimeUsage(agentSessionId, inputTokens, outputTokens, { userPayload, providers }) {
509
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
510
+ const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
511
+ const inputDelta = this.clampTokenDelta(inputTokens);
512
+ const outputDelta = this.clampTokenDelta(outputTokens);
513
+ if (inputDelta === 0 && outputDelta === 0) {
514
+ return true; // nothing to add — a no-op flush is a success, not a failure
515
+ }
516
+ const promptRunID = this.readPromptRunID(session);
517
+ if (!promptRunID) {
518
+ return false; // no observability prompt run for this session — logged in the helper
519
+ }
520
+ // Delegate to the service so usage writes share the per-run serialization with transcript-message
521
+ // appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
522
+ return this.clientSessionService.AccumulatePromptRunUsage(promptRunID, inputDelta, outputDelta, contextUser, provider);
523
+ }
524
+ /** Clamps a relayed token delta: negative / non-finite values become 0. */
525
+ clampTokenDelta(value) {
526
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
527
+ }
528
+ /**
529
+ * Reads the co-agent `AIPromptRun` id from the session config WITHOUT the relay path's
530
+ * throw-on-missing-target semantics — usage relay is best-effort, so a missing/malformed
531
+ * config or absent id just logs and returns `null`.
532
+ */
533
+ readPromptRunID(session) {
534
+ try {
535
+ const config = this.readSessionConfig(session);
536
+ if (config.promptRunID) {
537
+ return config.promptRunID;
538
+ }
539
+ LogStatus(`RelayRealtimeUsage: session ${session.ID} has no co-agent promptRunID (observability run ` +
540
+ 'creation was skipped or failed) — usage delta dropped.');
541
+ return null;
542
+ }
543
+ catch {
544
+ LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
545
+ return null;
546
+ }
547
+ }
548
+ /**
549
+ * Persist an interactive channel's state of record (e.g. the live whiteboard's serialized
550
+ * scene) onto the session's `MJ: AI Agent Session Channels` row.
551
+ *
552
+ * Flow:
553
+ * 1. Ownership gate — like the sibling mutations, the session's `UserID` must equal the
554
+ * caller's. Unlike Execute/Relay, a **Closed** session is accepted: the client's final
555
+ * on-end flush legitimately lands after `CloseAgentSession` has run.
556
+ * 2. Resolve the channel definition (`MJ: AI Agent Channels`) by `channelName`. When no
557
+ * active definition row exists (the deployment hasn't synced the channel seed yet),
558
+ * return `false` gracefully and log — never throw for a missing definition.
559
+ * 3. Offer the payload to the session's SERVER-SIDE channel plugin (the registry row's
560
+ * `ServerPluginClass`, held per-session by {@link RealtimeChannelServerHost}) for
561
+ * validation/normalization — strictly best-effort: no plugin, a plugin failure, or an
562
+ * oversized replacement all fall back to persisting the original payload.
563
+ * 4. Upsert the session-channel row: create it (Status `Connected`) when missing, store
564
+ * the (possibly normalized) state in its `Config` field, and stamp `LastActiveAt`.
565
+ *
566
+ * @returns `true` when the state was persisted; `false` on any tolerated failure (missing
567
+ * channel definition, oversized state, save failure) — all logged.
568
+ */
569
+ async SaveSessionChannelState(agentSessionId, channelName, stateJson, { userPayload, providers }) {
570
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
571
+ const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
572
+ if (stateJson.length > MAX_CHANNEL_STATE_CHARS) {
573
+ LogError(`SaveSessionChannelState: rejected oversized state for session ${agentSessionId} / channel '${channelName}' ` +
574
+ `(${stateJson.length} chars > ${MAX_CHANNEL_STATE_CHARS}).`);
575
+ return false;
576
+ }
577
+ const channelID = await this.resolveChannelID(channelName, contextUser, provider);
578
+ if (!channelID) {
579
+ return false; // missing/inactive channel definition — logged in resolveChannelID
580
+ }
581
+ const stateToPersist = await this.applyChannelServerPlugin(session.ID, channelName, stateJson);
582
+ return this.upsertSessionChannelState(session.ID, channelID, stateToPersist, contextUser, provider);
583
+ }
584
+ /**
585
+ * Routes a landed channel-state save through the session's server-side channel plugin (when
586
+ * one resolved at session start) so it can validate/normalize the payload PRE-persistence.
587
+ * Strictly best-effort: any host/plugin problem — including a replacement that exceeds the
588
+ * channel-state size cap — falls back to the client's original payload, which already passed
589
+ * the cap. A plugin can transform a save; it can never lose or block one.
590
+ */
591
+ async applyChannelServerPlugin(agentSessionID, channelName, stateJson) {
592
+ try {
593
+ const processed = await RealtimeChannelServerHost.Instance.OnChannelStateSave(agentSessionID, channelName, stateJson);
594
+ if (processed.length > MAX_CHANNEL_STATE_CHARS) {
595
+ LogError(`SaveSessionChannelState: server plugin for channel '${channelName}' returned an oversized replacement ` +
596
+ `(${processed.length} chars > ${MAX_CHANNEL_STATE_CHARS}) — persisting the original state.`);
597
+ return stateJson;
598
+ }
599
+ return processed;
600
+ }
601
+ catch (error) {
602
+ LogError(`SaveSessionChannelState: channel server plugin hook failed for session ${agentSessionID} / ` +
603
+ `channel '${channelName}' — persisting the original state: ${error.message}`);
604
+ return stateJson;
605
+ }
606
+ }
607
+ /**
608
+ * Persist an interactive channel's current state (e.g. the live whiteboard's board JSON) as a
609
+ * durable, user-owned **artifact** — distinct from {@link SaveSessionChannelState}, which only
610
+ * stores the session-scoped state of record. The artifact survives the session and shows up in
611
+ * the user's artifact library (and, when possible, in the conversation's history).
612
+ *
613
+ * Flow:
614
+ * 1. Ownership gate — like the sibling mutations (`UserID === contextUser.ID`). A **Closed**
615
+ * session is accepted: "save my board" legitimately arrives as/after the call ends.
616
+ * 2. Size cap — `contentJson` beyond {@link MAX_CHANNEL_STATE_CHARS} is a structured failure.
617
+ * 3. Resolve the {@link WHITEBOARD_ARTIFACT_TYPE_NAME} artifact type by name; when the seed is
618
+ * absent/disabled in this deployment, return a structured failure (never throw).
619
+ * 4. Create the `MJ: Artifacts` header (user-owned, Visibility `Always`) + its
620
+ * `MJ: Artifact Versions` v1 row carrying `contentJson`.
621
+ * 5. Best-effort extras that never fail the save:
622
+ * - when the session has a conversation AND a `Conversation Detail` stamped with this
623
+ * session id exists (the transcript relay stamps `AgentSessionID`), link the version into
624
+ * history via a `MJ: Conversation Detail Artifacts` junction row (Direction `Output`);
625
+ * - stamp `LastActiveAt` on the session-channel row when one exists.
626
+ *
627
+ * @returns A structured {@link SaveSessionChannelArtifactResult} — graceful failures (missing
628
+ * type seed, oversized content, save failure) come back as `Success: false`, while
629
+ * authn/ownership violations throw like the sibling mutations.
630
+ */
631
+ async SaveSessionChannelArtifact(agentSessionId, channelName, name, contentJson, { userPayload, providers }) {
632
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
633
+ const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
634
+ if (contentJson.length > MAX_CHANNEL_STATE_CHARS) {
635
+ const message = `SaveSessionChannelArtifact: rejected oversized content for session ${agentSessionId} / channel '${channelName}' ` +
636
+ `(${contentJson.length} chars > ${MAX_CHANNEL_STATE_CHARS}).`;
637
+ LogError(message);
638
+ return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
639
+ }
640
+ const typeID = await this.resolveWhiteboardArtifactTypeID(contextUser, provider);
641
+ if (!typeID) {
642
+ return {
643
+ Success: false,
644
+ ErrorMessage: `The '${WHITEBOARD_ARTIFACT_TYPE_NAME}' artifact type is not configured in this deployment — ` +
645
+ 'sync the artifact-type seed metadata to enable saving channel artifacts.',
646
+ ConversationDetailLinked: false,
647
+ };
648
+ }
649
+ return this.createChannelArtifact(session, channelName, name, contentJson, typeID, contextUser, provider);
650
+ }
651
+ // ----- internals -------------------------------------------------------------------------
652
+ /** Resolve the request user + read-write provider, throwing a clear error if unauthenticated. */
653
+ requireUserAndProvider(userPayload, providers) {
654
+ const contextUser = this.GetUserFromPayload(userPayload);
655
+ if (!contextUser) {
656
+ throw new Error('Not authenticated: no user context for realtime session operation');
657
+ }
658
+ return { contextUser, provider: GetReadWriteProvider(providers) };
659
+ }
660
+ /**
661
+ * Authorization gate for {@link RealtimeClientSessionResolver.StartRealtimeClientSession}: the
662
+ * caller must be able to run the **target** agent. Denial throws (no session is created).
663
+ */
664
+ async assertCanRunTarget(targetAgentId, contextUser, _provider) {
665
+ const canRun = await AIAgentPermissionHelper.HasPermission(targetAgentId, contextUser, 'run');
666
+ if (!canRun) {
667
+ throw new Error(`Not authorized: you are not permitted to run the target agent ${targetAgentId}`);
668
+ }
669
+ }
670
+ /**
671
+ * Resolves the AUTHORITATIVE target agent id under the co-agent's PAIRING CONSTRAINTS
672
+ * (`MJ: AI Agent Co Agents`, ordered by `Sequence`):
673
+ *
674
+ * - **Zero pairing rows (universal co-agent)** — today's behavior untouched: the runtime
675
+ * `targetAgentId` is required (clear error when absent) and used as-is. Pairings are NEVER
676
+ * mandated; zero-config deployments keep working with zero metadata.
677
+ * - **Rows + runtime target** — the supplied target must be IN the paired list; a target
678
+ * outside the list is a clear structured error (the UX builds its picker from the same rows).
679
+ * - **Rows + no runtime target** — the `IsDefault` row stands in; when no default row exists,
680
+ * a clear error asks for an explicit target.
681
+ *
682
+ * Pairing rows are a TARGETING constraint layered on top of — never a replacement for — the
683
+ * `CanRun` security gate, which the caller applies to the resolved target id immediately
684
+ * after. A failed/erroring pairing query therefore degrades to the universal behavior
685
+ * (logged), it never breaks session starts.
686
+ *
687
+ * @param coAgentID The resolved co-agent id.
688
+ * @param requestedTargetId The runtime `targetAgentId` argument, when supplied.
689
+ * @returns The effective target agent id (canonical casing from the pairing row when matched).
690
+ */
691
+ async resolveConstrainedTargetAgentID(coAgentID, requestedTargetId, contextUser, provider) {
692
+ const rows = await this.loadPairingRows(coAgentID, contextUser, provider);
693
+ if (rows.length === 0) {
694
+ if (!requestedTargetId) {
695
+ throw new Error('targetAgentId is required: this co-agent has no paired targets ' +
696
+ '(it is universal), so there is no default target to fall back to.');
697
+ }
698
+ return requestedTargetId;
699
+ }
700
+ if (requestedTargetId) {
701
+ const match = rows.find((r) => UUIDsEqual(r.TargetAgentID, requestedTargetId));
702
+ if (!match) {
703
+ throw new Error(`Invalid targetAgentId '${requestedTargetId}': this co-agent is paired to a specific ` +
704
+ 'target list and the requested agent is not in it. Pick one of the paired targets ' +
705
+ 'or use a co-agent without pairings.');
706
+ }
707
+ return match.TargetAgentID;
708
+ }
709
+ const defaultRow = rows.find((r) => r.IsDefault);
710
+ if (!defaultRow) {
711
+ throw new Error('targetAgentId is required: this co-agent is paired to a target list but no pairing is ' +
712
+ 'marked IsDefault, so there is no default target to fall back to.');
713
+ }
714
+ return defaultRow.TargetAgentID;
715
+ }
716
+ /**
717
+ * Loads the co-agent's pairing rows from {@link AIEngineBase}'s cached
718
+ * `MJ: AI Agent Co Agents` metadata (provider-scoped engine instance, lazy `Config`),
719
+ * ordered by `Sequence`. Only **Active** rows of relationship **Type `'CoAgent'`** with a
720
+ * SPECIFIC agent target participate — type-level rows (`TargetAgentTypeID`) express the
721
+ * type-default in the resolution chain, not a target restriction, and reserved relationship
722
+ * types are ignored until their features ship. Tolerant — a failed cache load logs and
723
+ * returns `[]` (degrading to the universal behavior; pairing is a targeting constraint,
724
+ * `CanRun` remains the security gate), never throws.
725
+ */
726
+ async loadPairingRows(coAgentID, contextUser, provider) {
727
+ try {
728
+ const engine = await this.configuredAIEngineBase(contextUser, provider);
729
+ return (engine.AgentCoAgents ?? [])
730
+ .filter((r) => r.Type === 'CoAgent' &&
731
+ r.Status === 'Active' &&
732
+ r.TargetAgentID != null &&
733
+ UUIDsEqual(r.CoAgentID, coAgentID))
734
+ .sort((a, b) => (a.Sequence ?? 0) - (b.Sequence ?? 0))
735
+ .map((r) => ({ TargetAgentID: r.TargetAgentID, IsDefault: r.IsDefault, Sequence: r.Sequence }));
736
+ }
737
+ catch (error) {
738
+ LogError(`StartRealtimeClientSession: ${CO_AGENT_ENTITY} cache read failed for co-agent ${coAgentID} ` +
739
+ `(${error.message}) — treating the co-agent as universal.`);
740
+ return [];
741
+ }
742
+ }
743
+ /**
744
+ * The provider-scoped {@link AIEngineBase} instance for this request's connection, lazily
745
+ * configured (`Config(false, ...)` is a no-op when the cache is already loaded). Pairing rows
746
+ * and channel definitions are small metadata tables the engine caches — reading them here
747
+ * replaces per-call RunViews.
748
+ */
749
+ async configuredAIEngineBase(contextUser, provider) {
750
+ const engine = AIEngineBase.GetProviderInstance(provider, AIEngineBase);
751
+ await engine.Config(false, contextUser, provider);
752
+ return engine;
753
+ }
754
+ /**
755
+ * The RUNTIME-OVERRIDE AUTHORIZATION GATE: `configOverridesJson` and a DEVIATING explicit
756
+ * `preferredModelId` both require the `Realtime: Advanced Session Controls` authorization
757
+ * (evaluated hierarchy-aware over the caller's roles); a deviating model is additionally
758
+ * subject to the effective `realtime.allowUserModelOverride` policy (which blocks even
759
+ * authorized callers when `false`). Denial THROWS a structured reason — never a silent
760
+ * ignore. Plain starts (no overrides, no explicit model) pass through untouched; an explicit
761
+ * model EQUAL to the co-agent's metadata-configured preference is not a deviation.
762
+ *
763
+ * Judgment calls baked in (per the approved product rules): co-agent selection (`coAgentId`)
764
+ * and target selection within a pairing list / for a universal co-agent are NORMAL user flow
765
+ * — they stay behind the existing `CanRun` gate only and are not touched here.
766
+ */
767
+ async assertRuntimeOverridesAuthorized(coAgentID, configOverridesJson, preferredModelId, contextUser, provider) {
768
+ if (!configOverridesJson && !preferredModelId) {
769
+ return;
770
+ }
771
+ if (configOverridesJson && !ParseRealtimeTypeConfiguration(configOverridesJson)) {
772
+ throw new Error('Invalid configOverridesJson: expected a JSON object.');
773
+ }
774
+ const baseline = this.resolveBaselineEffectiveConfig(coAgentID);
775
+ const decision = EvaluateRuntimeOverrideAuthorization({
776
+ HasConfigOverrides: !!configOverridesJson,
777
+ RequestedModelID: preferredModelId ?? null,
778
+ MetadataPreferredModelID: this.resolveMetadataPreferredModelID(baseline),
779
+ AllowUserModelOverride: baseline.realtime?.allowUserModelOverride,
780
+ CallerHasAdvancedControls: this.userHasAdvancedSessionControls(contextUser, provider),
781
+ });
782
+ if (!decision.Allowed) {
783
+ throw new Error(`Not authorized: ${decision.DenialReason}`);
784
+ }
785
+ }
786
+ /**
787
+ * The co-agent's BASELINE effective configuration — type `DefaultConfiguration` ← agent
788
+ * `TypeConfiguration`, WITHOUT the runtime layer (this feeds the gate that decides whether
789
+ * the runtime layer is even allowed). Tolerant of an unloaded metadata cache.
790
+ */
791
+ resolveBaselineEffectiveConfig(coAgentID) {
792
+ try {
793
+ const agent = (AIEngine.Instance.Agents ?? []).find((a) => UUIDsEqual(a.ID, coAgentID));
794
+ let typeDefault = null;
795
+ if (agent?.TypeID) {
796
+ const type = (AIEngine.Instance.AgentTypes ?? []).find((t) => UUIDsEqual(t.ID, agent.TypeID));
797
+ typeDefault = type?.DefaultConfiguration ?? null;
798
+ }
799
+ return ResolveEffectiveRealtimeConfig(typeDefault, agent?.TypeConfiguration ?? null, null);
800
+ }
801
+ catch {
802
+ return {};
803
+ }
804
+ }
805
+ /**
806
+ * Resolves the baseline config's `realtime.modelPreference` (Name or ID) to an
807
+ * `MJ: AI Models.ID`, so a `preferredModelId` equal to it is recognized as a NON-deviation.
808
+ * When the preference matches no cached model (or the cache is unavailable), the RAW
809
+ * preference string is returned — an ID-style preference then still compares equal to the
810
+ * matching `preferredModelId` (case-insensitive), while a name-style preference simply won't
811
+ * match an id (and the gate treats the request as a deviation — the fail-safe direction).
812
+ * `null` only when no preference is configured at all.
813
+ */
814
+ resolveMetadataPreferredModelID(baseline) {
815
+ const preference = baseline.realtime?.modelPreference;
816
+ if (!preference) {
817
+ return null;
818
+ }
819
+ try {
820
+ const models = AIEngine.Instance.Models ?? [];
821
+ const wanted = preference.trim().toLowerCase();
822
+ const matched = models.find((m) => UUIDsEqual(m.ID, preference)) ??
823
+ models.find((m) => m.Name?.trim().toLowerCase() === wanted);
824
+ return matched?.ID ?? preference;
825
+ }
826
+ catch {
827
+ return preference;
828
+ }
829
+ }
830
+ /**
831
+ * Hierarchy-aware check for the `Realtime: Advanced Session Controls` authorization against
832
+ * the request provider's cached Authorizations + the caller's roles. FAIL-CLOSED: an absent
833
+ * authorization row (un-synced seed) or an evaluation error denies — runtime overrides are a
834
+ * privileged path, and unauthorized callers still get a fully working session without them.
835
+ */
836
+ userHasAdvancedSessionControls(contextUser, provider) {
837
+ try {
838
+ const auths = provider.Authorizations ?? [];
839
+ const wanted = REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION.trim().toLowerCase();
840
+ const auth = auths.find((a) => a.Name?.trim().toLowerCase() === wanted);
841
+ if (!auth) {
842
+ LogError(`StartRealtimeClientSession: the '${REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION}' ` +
843
+ 'authorization is not present in metadata — runtime overrides are denied (fail closed). ' +
844
+ 'Sync the authorization seed metadata to enable them.');
845
+ return false;
846
+ }
847
+ return new AuthorizationEvaluator().UserCanExecuteWithAncestors(auth, contextUser, auths);
848
+ }
849
+ catch (error) {
850
+ LogError(`StartRealtimeClientSession: authorization evaluation failed (${error.message}) — ` +
851
+ 'runtime overrides are denied (fail closed).');
852
+ return false;
853
+ }
854
+ }
855
+ /**
856
+ * Resolves the co-agent (the Realtime-type agent that voices the target agent) for a new
857
+ * client-direct session via the metadata-driven **CO-AGENT RESOLUTION CHAIN** — first match
858
+ * wins, evaluated in precedence order:
859
+ *
860
+ * 1. **Runtime parameter** — the mutation's explicit `coAgentId`. A per-call override; an
861
+ * invalid candidate (unknown, not Active, or not of the Realtime agent type) **throws**
862
+ * (fail loud — the caller asked for something specific, mirroring `preferredModelId`).
863
+ * 2. **Per-agent persona** — the target agent's `AIAgent.DefaultCoAgentID`. An invalid
864
+ * reference logs a warning and **falls through** to the next step (stale metadata should
865
+ * degrade gracefully, never break calls).
866
+ * 3. **Per-type default** — an Active `AIAgentCoAgent` row of Type `'CoAgent'` whose
867
+ * `TargetAgentTypeID` is the target agent's type and `IsDefault = 1` (lowest `Sequence`
868
+ * wins a tie). Same tolerant warn-and-fall-through semantics as step 2.
869
+ * 4. **Global default** — the seeded {@link REALTIME_CO_AGENT_NAME} agent, looked up by name
870
+ * (with a deprecated fallback to {@link LEGACY_REALTIME_CO_AGENT_NAME}). Throws when absent
871
+ * entirely (the realtime feature is unconfigured in this deployment).
872
+ *
873
+ * Every candidate from steps 1–3 is validated by {@link findValidCoAgent}: it must exist in
874
+ * {@link AIEngine}'s cached agents, have Status `Active`, and be of the
875
+ * {@link REALTIME_AGENT_TYPE_NAME} agent type. Step 4 keeps its original name-lookup contract.
876
+ *
877
+ * @param targetAgentId The agent the co-agent will voice on behalf of (drives steps 2–3).
878
+ * @param explicitCoAgentId The runtime `coAgentId` mutation argument, when supplied (step 1).
879
+ * @returns The resolved co-agent's id (canonical casing from the metadata cache).
880
+ */
881
+ async resolveCoAgentID(targetAgentId, explicitCoAgentId, contextUser, provider) {
882
+ await AIEngine.Instance.Config(false, contextUser, provider);
883
+ // Step 1 — explicit runtime parameter: fail LOUD on any problem.
884
+ if (explicitCoAgentId) {
885
+ const { agent, problem } = this.findValidCoAgent(explicitCoAgentId);
886
+ if (!agent) {
887
+ throw new Error(`Invalid coAgentId '${explicitCoAgentId}': ${problem}`);
888
+ }
889
+ return agent.ID;
890
+ }
891
+ const targetAgent = targetAgentId
892
+ ? (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, targetAgentId))
893
+ : undefined;
894
+ // Step 2 — the target agent's own DefaultCoAgentID (per-agent persona): warn + fall through.
895
+ const fromAgent = this.resolveMetadataDefault(targetAgent?.DefaultCoAgentID, `agent '${targetAgent?.Name}' (DefaultCoAgentID)`);
896
+ if (fromAgent) {
897
+ return fromAgent;
898
+ }
899
+ // Step 3 — the type-level AIAgentCoAgent default row (per-type default): warn + fall through.
900
+ const agentType = targetAgent?.TypeID
901
+ ? (AIEngine.Instance.AgentTypes ?? []).find(t => UUIDsEqual(t.ID, targetAgent.TypeID))
902
+ : undefined;
903
+ const typeDefaultCoAgentID = agentType
904
+ ? this.findTypeDefaultCoAgentID(agentType.ID, contextUser, provider)
905
+ : undefined;
906
+ const fromType = this.resolveMetadataDefault(await typeDefaultCoAgentID, `agent type '${agentType?.Name}' (AIAgentCoAgent type-default row)`);
907
+ if (fromType) {
908
+ return fromType;
909
+ }
910
+ // Step 4 — global default: the seeded Realtime Co-Agent, by name (original behavior).
911
+ return this.resolveGlobalCoAgentID();
912
+ }
913
+ /**
914
+ * Chain step 3 lookup: the TYPE-LEVEL default co-agent for an agent type — the Active
915
+ * `AIAgentCoAgent` row of Type `'CoAgent'` whose `TargetAgentTypeID` matches, with
916
+ * `IsDefault = 1` preferred and the lowest `Sequence` breaking ties (so a deployment can
917
+ * stage multiple type-level candidates deterministically). Reads {@link AIEngineBase}'s
918
+ * cached rows — no RunView. Tolerant: a failed cache read logs and returns `undefined`
919
+ * (the chain falls through to the global default).
920
+ */
921
+ async findTypeDefaultCoAgentID(agentTypeID, contextUser, provider) {
922
+ try {
923
+ const engine = await this.configuredAIEngineBase(contextUser, provider);
924
+ const candidates = (engine.AgentCoAgents ?? [])
925
+ .filter((r) => r.Type === 'CoAgent' &&
926
+ r.Status === 'Active' &&
927
+ r.TargetAgentTypeID != null &&
928
+ UUIDsEqual(r.TargetAgentTypeID, agentTypeID))
929
+ .sort((a, b) => (a.IsDefault === b.IsDefault ? 0 : a.IsDefault ? -1 : 1) ||
930
+ (a.Sequence ?? 0) - (b.Sequence ?? 0));
931
+ return candidates[0]?.CoAgentID;
932
+ }
933
+ catch (error) {
934
+ LogError(`StartRealtimeClientSession: ${CO_AGENT_ENTITY} cache read failed while resolving the type-level ` +
935
+ `default co-agent for agent type ${agentTypeID} (${error.message}) — falling through.`);
936
+ return undefined;
937
+ }
938
+ }
939
+ /**
940
+ * Validates one metadata-level co-agent default (chain steps 2/3). Returns the resolved
941
+ * co-agent id when the reference is valid; logs a warning and returns `null` (caller falls
942
+ * through to the next chain step) when the reference is set but invalid — metadata drift must
943
+ * degrade, not break live calls. A `null`/absent reference returns `null` silently.
944
+ */
945
+ resolveMetadataDefault(candidateId, source) {
946
+ if (!candidateId) {
947
+ return null;
948
+ }
949
+ const { agent, problem } = this.findValidCoAgent(candidateId);
950
+ if (agent) {
951
+ return agent.ID;
952
+ }
953
+ LogError(`StartRealtimeClientSession: ignoring co-agent default '${candidateId}' from ${source} — ${problem} ` +
954
+ 'Falling through to the next step of the co-agent resolution chain.');
955
+ return null;
956
+ }
957
+ /**
958
+ * Validates a co-agent candidate against {@link AIEngine}'s cached metadata. A valid co-agent
959
+ * must (a) exist, (b) have Status `Active`, and (c) be of the {@link REALTIME_AGENT_TYPE_NAME}
960
+ * agent type. Returns the cached agent on success, or a human-readable `problem` on failure —
961
+ * the CALLER decides whether that's fatal (explicit runtime param) or tolerated (metadata default).
962
+ */
963
+ findValidCoAgent(candidateId) {
964
+ const agent = (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, candidateId));
965
+ if (!agent) {
966
+ return { problem: 'no agent with that ID exists.' };
967
+ }
968
+ if (agent.Status !== 'Active') {
969
+ return { problem: `agent '${agent.Name}' is not Active (Status: ${agent.Status}).` };
970
+ }
971
+ const realtimeType = (AIEngine.Instance.AgentTypes ?? []).find(t => t.Name?.trim().toLowerCase() === REALTIME_AGENT_TYPE_NAME.toLowerCase());
972
+ if (!realtimeType) {
973
+ return { problem: `the '${REALTIME_AGENT_TYPE_NAME}' agent type is not configured in this deployment.` };
974
+ }
975
+ if (!agent.TypeID || !UUIDsEqual(agent.TypeID, realtimeType.ID)) {
976
+ return { problem: `agent '${agent.Name}' is not of the '${REALTIME_AGENT_TYPE_NAME}' agent type.` };
977
+ }
978
+ return { agent };
979
+ }
980
+ /**
981
+ * Resolves the seeded Realtime Co-Agent's id from {@link AIEngine}'s cached agents — the GLOBAL
982
+ * DEFAULT (step 4) of the co-agent resolution chain. The engine is already configured by
983
+ * {@link resolveCoAgentID}. Looks up the current {@link REALTIME_CO_AGENT_NAME} first, then
984
+ * falls back to the DEPRECATED {@link LEGACY_REALTIME_CO_AGENT_NAME} (pre-rename seed) with a
985
+ * deprecation log so un-resynced deployments keep working. Throws a clear error when neither
986
+ * name is present in metadata.
987
+ */
988
+ resolveGlobalCoAgentID() {
989
+ const coAgent = this.findAgentByName(REALTIME_CO_AGENT_NAME);
990
+ if (coAgent) {
991
+ return coAgent.ID;
992
+ }
993
+ const legacy = this.findAgentByName(LEGACY_REALTIME_CO_AGENT_NAME);
994
+ if (legacy) {
995
+ LogStatus(`StartRealtimeClientSession: resolved the global co-agent via its DEPRECATED legacy name ` +
996
+ `'${LEGACY_REALTIME_CO_AGENT_NAME}'. Re-sync the agent seed metadata to rename it to ` +
997
+ `'${REALTIME_CO_AGENT_NAME}'.`);
998
+ return legacy.ID;
999
+ }
1000
+ throw new Error(`The '${REALTIME_CO_AGENT_NAME}' agent is not configured; cannot start a realtime voice session.`);
1001
+ }
1002
+ /** Case/whitespace-insensitive agent lookup by Name in {@link AIEngine}'s cached agents. */
1003
+ findAgentByName(name) {
1004
+ const wanted = name.toLowerCase();
1005
+ return (AIEngine.Instance.Agents ?? []).find(a => a.Name?.trim().toLowerCase() === wanted);
1006
+ }
1007
+ /**
1008
+ * Mints the client session config for a just-created session. On failure, closes the session so
1009
+ * no half-open record leaks, then throws the service's error message.
1010
+ *
1011
+ * @param preferredModelId Optional explicit realtime model choice — threaded to the service,
1012
+ * which FAILS (no silent fallback) when the chosen model cannot be satisfied.
1013
+ * @param priorTranscript Optional capped, role-tagged transcript of the PRIOR session chain
1014
+ * (from {@link loadPriorTranscript}) — the service frames it into the system prompt so a
1015
+ * resumed session remembers the previous leg(s).
1016
+ */
1017
+ async prepareClientSessionOrClose(session, coAgentID, targetAgentId, contextUser, provider, preferredModelId, clientTools, priorTranscript, configOverridesJson) {
1018
+ const prep = await this.clientSessionService.PrepareClientSession({
1019
+ CoAgentID: coAgentID,
1020
+ TargetAgentID: targetAgentId,
1021
+ AgentSessionID: session.ID,
1022
+ ConversationID: session.ConversationID ?? undefined,
1023
+ // MVP: conversation history is not yet hydrated into ChatMessage[]; the co-agent
1024
+ // companion prompt runs without prior turns. A later phase loads the session's
1025
+ // Conversation into ChatMessage[] for richer context.
1026
+ ConversationMessages: [],
1027
+ UserID: contextUser.ID,
1028
+ PreferredModelID: preferredModelId,
1029
+ // Client-declared, CLIENT-EXECUTED UI tools (see the mutation's SECURITY NOTE) —
1030
+ // merged after invoke-target-agent into the declared tool set.
1031
+ ExtraTools: clientTools,
1032
+ // Resume continuity: the prior leg(s)' transcript, framed into the system prompt.
1033
+ PriorTranscript: priorTranscript,
1034
+ // Pre-authorized runtime override layer (assertRuntimeOverridesAuthorized gated it).
1035
+ ConfigOverridesJson: configOverridesJson,
1036
+ }, contextUser, provider);
1037
+ if (!prep.Success || !prep.ClientConfig) {
1038
+ // Prep failure is an ERROR close, not an explicit user hang-up — stamp it as such.
1039
+ await this.sessionManager.CloseSession(session.ID, contextUser, provider, 'Error');
1040
+ throw new Error(prep.ErrorMessage ?? 'Failed to prepare the client realtime session.');
1041
+ }
1042
+ await this.persistObservabilityRunIDs(session, targetAgentId, prep.CoAgentRunID, prep.PromptRunID, prep.CoAgentRunStepID);
1043
+ const cfg = prep.ClientConfig;
1044
+ return {
1045
+ AgentSessionId: session.ID,
1046
+ ConversationId: session.ConversationID ?? '',
1047
+ Provider: cfg.Provider,
1048
+ Model: cfg.Model,
1049
+ EphemeralToken: cfg.EphemeralToken,
1050
+ ExpiresAt: cfg.ExpiresAt,
1051
+ SessionConfigJson: JSON.stringify(cfg.SessionConfig),
1052
+ ModelName: prep.ModelName,
1053
+ NarrationInstructionsTemplate: prep.NarrationInstructionsTemplate,
1054
+ NarrationPaceMs: prep.NarrationPaceMs,
1055
+ EffectiveConfigJson: prep.EffectiveConfig ? JSON.stringify(prep.EffectiveConfig) : undefined,
1056
+ };
1057
+ }
1058
+ /**
1059
+ * Writes the co-agent observability run ids into the session's `Config_` alongside the
1060
+ * authoritative `targetAgentID`, then saves the session. These ids are read back on close to
1061
+ * finalize the runs (and on relay to nest delegated runs). Best-effort: a save failure is logged,
1062
+ * not thrown — the voice session still proceeds, it just won't carry the run ids.
1063
+ */
1064
+ async persistObservabilityRunIDs(session, targetAgentID, coAgentRunID, promptRunID, coAgentRunStepID) {
1065
+ const config = { targetAgentID, coAgentRunID, promptRunID, coAgentRunStepID };
1066
+ session.Config_ = JSON.stringify(config);
1067
+ const saved = await session.Save();
1068
+ if (!saved) {
1069
+ LogError(`RealtimeClientSessionResolver.persistObservabilityRunIDs save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1070
+ }
1071
+ }
1072
+ /**
1073
+ * Loads a session, enforcing inbound ownership (`UserID === contextUser.ID`) and that it is not
1074
+ * `Closed`. Throws on any violation. Returns the loaded session for the caller to read from.
1075
+ */
1076
+ async loadOwnedActiveSession(agentSessionId, contextUser, provider) {
1077
+ const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
1078
+ if (session.Status === 'Closed') {
1079
+ throw new Error(`Realtime session ${agentSessionId} is closed`);
1080
+ }
1081
+ return session;
1082
+ }
1083
+ /**
1084
+ * Loads a session and enforces inbound ownership (`UserID === contextUser.ID`) WITHOUT
1085
+ * rejecting `Closed` sessions — {@link SaveSessionChannelState}'s final on-end flush
1086
+ * legitimately arrives after the session closed. Throws when not found / not owned.
1087
+ */
1088
+ async loadOwnedSession(agentSessionId, contextUser, provider) {
1089
+ const session = await provider.GetEntityObject(SESSION_ENTITY, contextUser);
1090
+ const loaded = await session.Load(agentSessionId);
1091
+ if (!loaded) {
1092
+ throw new Error(`Realtime session ${agentSessionId} not found`);
1093
+ }
1094
+ if (!UUIDsEqual(session.UserID, contextUser.ID)) {
1095
+ LogError(`RealtimeClientSessionResolver: ownership check failed — user ${contextUser.ID} attempted to operate on session ${agentSessionId} owned by ${session.UserID}`);
1096
+ throw new Error('Not authorized: you do not own this realtime session');
1097
+ }
1098
+ return session;
1099
+ }
1100
+ /**
1101
+ * Resolves an ACTIVE channel definition (`MJ: AI Agent Channels`) by name from
1102
+ * {@link AIEngineBase}'s cached `AgentChannels` (provider-scoped engine instance, lazy
1103
+ * `Config` — no per-call RunView; name matching is trim + case-insensitive, parity with the
1104
+ * previous SQL-collation lookup). Returns its id, or `null` (logged) when no active
1105
+ * definition exists — the channel seed is deployed separately, so its absence is tolerated,
1106
+ * never thrown.
1107
+ */
1108
+ async resolveChannelID(channelName, contextUser, provider) {
1109
+ const wanted = channelName.trim().toLowerCase();
1110
+ let row;
1111
+ try {
1112
+ const engine = await this.configuredAIEngineBase(contextUser, provider);
1113
+ row = (engine.AgentChannels ?? []).find((c) => c.Name?.trim().toLowerCase() === wanted);
1114
+ }
1115
+ catch (error) {
1116
+ LogError(`RealtimeClientSessionResolver: ${CHANNEL_ENTITY} cache read failed while resolving channel ` +
1117
+ `'${channelName}' (${error.message}) — channel operation skipped.`);
1118
+ return null;
1119
+ }
1120
+ if (!row || !row.IsActive) {
1121
+ LogError(`RealtimeClientSessionResolver: no active '${channelName}' channel definition found in ${CHANNEL_ENTITY} — ` +
1122
+ 'channel operation skipped (sync the channel seed metadata to enable it).');
1123
+ return null;
1124
+ }
1125
+ return row.ID;
1126
+ }
1127
+ /**
1128
+ * Upserts the session-channel row for `(agentSessionID, channelID)`: creates it with Status
1129
+ * `Connected` when missing, stores `stateJson` in `Config`, and stamps `LastActiveAt`.
1130
+ * Returns the boolean save result (logged on failure).
1131
+ */
1132
+ async upsertSessionChannelState(agentSessionID, channelID, stateJson, contextUser, provider) {
1133
+ const rv = RunView.FromMetadataProvider(provider);
1134
+ const existing = await rv.RunView({
1135
+ EntityName: SESSION_CHANNEL_ENTITY,
1136
+ ExtraFilter: `AgentSessionID='${agentSessionID}' AND ChannelID='${channelID}'`,
1137
+ ResultType: 'entity_object',
1138
+ MaxRows: 1,
1139
+ }, contextUser);
1140
+ let row = existing.Success ? existing.Results?.[0] : undefined;
1141
+ if (!row) {
1142
+ row = await provider.GetEntityObject(SESSION_CHANNEL_ENTITY, contextUser);
1143
+ row.NewRecord();
1144
+ row.AgentSessionID = agentSessionID;
1145
+ row.ChannelID = channelID;
1146
+ row.Status = 'Connected';
1147
+ }
1148
+ row.Config_ = stateJson;
1149
+ row.LastActiveAt = new Date();
1150
+ const saved = await row.Save();
1151
+ if (!saved) {
1152
+ LogError(`SaveSessionChannelState: save failed for session ${agentSessionID} / channel ${channelID}: ` +
1153
+ `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1154
+ }
1155
+ return saved;
1156
+ }
1157
+ /**
1158
+ * Loads the PRIOR session's persisted channel states and serializes them as a JSON object
1159
+ * string keyed by channel NAME (the session-channel view denormalizes the channel name as the
1160
+ * `Channel` virtual field). Strictly best-effort — every failure path logs and returns `null`,
1161
+ * a session start NEVER fails because of restore:
1162
+ * - no `lastSessionId` → `null` (silently — restore simply wasn't requested);
1163
+ * - prior session missing OR owned by a different user → `null` (logged — never leak another
1164
+ * user's channel state);
1165
+ * - rows with an empty `Config` are skipped;
1166
+ * - an individual state larger than {@link MAX_CHANNEL_STATE_CHARS} is dropped (logged), and
1167
+ * states that would push the accumulated payload past that same cap are dropped too (logged)
1168
+ * — the surviving states still restore;
1169
+ * - no surviving states → `null`.
1170
+ */
1171
+ async loadPriorChannelStatesJson(lastSessionId, contextUser, provider) {
1172
+ if (!lastSessionId) {
1173
+ return null;
1174
+ }
1175
+ try {
1176
+ const prior = await provider.GetEntityObject(SESSION_ENTITY, contextUser);
1177
+ if (!(await prior.Load(lastSessionId))) {
1178
+ LogError(`StartRealtimeClientSession: prior session ${lastSessionId} not found — skipping channel-state restore.`);
1179
+ return null;
1180
+ }
1181
+ if (!UUIDsEqual(prior.UserID, contextUser.ID)) {
1182
+ LogError(`StartRealtimeClientSession: prior session ${lastSessionId} is not owned by user ${contextUser.ID} — ` +
1183
+ 'skipping channel-state restore.');
1184
+ return null;
1185
+ }
1186
+ const safeID = lastSessionId.replace(/'/g, "''");
1187
+ const rv = RunView.FromMetadataProvider(provider);
1188
+ const result = await rv.RunView({
1189
+ EntityName: SESSION_CHANNEL_ENTITY,
1190
+ ExtraFilter: `AgentSessionID='${safeID}'`,
1191
+ Fields: ['Channel', 'Config'],
1192
+ ResultType: 'simple',
1193
+ }, contextUser);
1194
+ if (!result.Success) {
1195
+ LogError(`StartRealtimeClientSession: channel-state restore query failed for prior session ${lastSessionId}: ${result.ErrorMessage}`);
1196
+ return null;
1197
+ }
1198
+ const states = this.collectChannelStates(result.Results ?? [], lastSessionId);
1199
+ return Object.keys(states).length > 0 ? JSON.stringify(states) : null;
1200
+ }
1201
+ catch (error) {
1202
+ LogError(`StartRealtimeClientSession: channel-state restore failed for prior session ${lastSessionId}: ${error.message}`);
1203
+ return null;
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Loads the PRIOR session chain's transcript for model-context hydration: when a new session
1208
+ * carries `lastSessionId`, the chain's persisted, session-stamped `Conversation Details` are
1209
+ * folded into capped, role-tagged lines (`User: …` / `Assistant: …`) the prepare service
1210
+ * frames into the system prompt — so the model REMEMBERS the previous live leg(s).
1211
+ *
1212
+ * Chain walk + caps:
1213
+ * - follows `LastSessionID` BACKWARDS from `lastSessionId`, at most
1214
+ * {@link MAX_PRIOR_TRANSCRIPT_LEGS} legs, with a visited-set cycle guard (A→B→A stops);
1215
+ * - EVERY leg is ownership-checked like the channel-state restore — the FIRST leg failing
1216
+ * the check aborts hydration entirely (first leg) or ends the walk (deeper legs), so
1217
+ * another user's transcript can never leak;
1218
+ * - one details query covers all collected legs, chronological;
1219
+ * - hidden/error/empty rows are skipped, then the NEWEST {@link MAX_PRIOR_TRANSCRIPT_TURNS}
1220
+ * turns are kept and the total is capped at {@link MAX_PRIOR_TRANSCRIPT_CHARS} chars
1221
+ * (oldest dropped first).
1222
+ *
1223
+ * Strictly best-effort: every failure path logs and returns `undefined` — hydration NEVER
1224
+ * blocks a session start.
1225
+ */
1226
+ async loadPriorTranscript(lastSessionId, contextUser, provider) {
1227
+ if (!lastSessionId) {
1228
+ return undefined;
1229
+ }
1230
+ try {
1231
+ const legIDs = await this.collectOwnedPriorLegIDs(lastSessionId, contextUser, provider);
1232
+ if (legIDs.length === 0) {
1233
+ return undefined;
1234
+ }
1235
+ const turns = await this.loadChainTranscriptTurns(legIDs, contextUser, provider);
1236
+ const lines = this.capTranscriptLines(turns);
1237
+ return lines.length > 0 ? lines.join('\n') : undefined;
1238
+ }
1239
+ catch (error) {
1240
+ LogError(`StartRealtimeClientSession: prior-transcript hydration failed for session ${lastSessionId}: ${error.message}`);
1241
+ return undefined;
1242
+ }
1243
+ }
1244
+ /**
1245
+ * Walks the prior-session chain backwards from `lastSessionId`, returning the OWNED leg ids
1246
+ * (newest first). The first leg must exist and be owned by the caller, else `[]` (logged);
1247
+ * deeper-leg problems (missing, unowned, cycle) just end the walk.
1248
+ */
1249
+ async collectOwnedPriorLegIDs(lastSessionId, contextUser, provider) {
1250
+ const legIDs = [];
1251
+ const visited = new Set();
1252
+ let cursor = lastSessionId;
1253
+ while (cursor && legIDs.length < MAX_PRIOR_TRANSCRIPT_LEGS) {
1254
+ const key = cursor.trim().toLowerCase();
1255
+ if (visited.has(key)) {
1256
+ LogError(`StartRealtimeClientSession: prior-session chain cycle detected at ${cursor} — stopping the transcript walk.`);
1257
+ break;
1258
+ }
1259
+ visited.add(key);
1260
+ const leg = await provider.GetEntityObject(SESSION_ENTITY, contextUser);
1261
+ if (!(await leg.Load(cursor))) {
1262
+ if (legIDs.length === 0) {
1263
+ LogError(`StartRealtimeClientSession: prior session ${cursor} not found — skipping transcript hydration.`);
1264
+ }
1265
+ break;
1266
+ }
1267
+ if (!UUIDsEqual(leg.UserID, contextUser.ID)) {
1268
+ if (legIDs.length === 0) {
1269
+ LogError(`StartRealtimeClientSession: prior session ${cursor} is not owned by user ${contextUser.ID} — ` +
1270
+ 'skipping transcript hydration.');
1271
+ }
1272
+ break;
1273
+ }
1274
+ legIDs.push(leg.ID);
1275
+ cursor = leg.LastSessionID ?? null;
1276
+ }
1277
+ return legIDs;
1278
+ }
1279
+ /**
1280
+ * Loads the chain legs' visible transcript turns (session-stamped `Conversation Details`,
1281
+ * `Role` User/AI, not hidden, non-empty) in ONE chronological query across all legs.
1282
+ */
1283
+ async loadChainTranscriptTurns(legIDs, contextUser, provider) {
1284
+ const inList = legIDs.map((id) => `'${id.replace(/'/g, "''")}'`).join(',');
1285
+ const rv = RunView.FromMetadataProvider(provider);
1286
+ const result = await rv.RunView({
1287
+ EntityName: CONVERSATION_DETAIL_ENTITY,
1288
+ ExtraFilter: `AgentSessionID IN (${inList})`,
1289
+ Fields: ['ID', 'Role', 'Message', 'HiddenToUser', '__mj_CreatedAt'],
1290
+ OrderBy: '__mj_CreatedAt ASC',
1291
+ ResultType: 'simple',
1292
+ }, contextUser);
1293
+ if (!result.Success) {
1294
+ LogError(`StartRealtimeClientSession: prior-transcript query failed: ${result.ErrorMessage}`);
1295
+ return [];
1296
+ }
1297
+ return (result.Results ?? []).filter((row) => !row.HiddenToUser &&
1298
+ (row.Role === 'User' || row.Role === 'AI') &&
1299
+ typeof row.Message === 'string' &&
1300
+ row.Message.trim().length > 0);
1301
+ }
1302
+ /**
1303
+ * Maps visible turns to role-tagged lines and applies the hydration caps: the NEWEST
1304
+ * {@link MAX_PRIOR_TRANSCRIPT_TURNS} turns, then a total budget of
1305
+ * {@link MAX_PRIOR_TRANSCRIPT_CHARS} chars — oldest lines dropped first in both passes,
1306
+ * so the model always keeps the freshest context.
1307
+ */
1308
+ capTranscriptLines(turns) {
1309
+ const newest = turns.slice(-MAX_PRIOR_TRANSCRIPT_TURNS);
1310
+ const lines = newest.map((t) => `${t.Role === 'User' ? 'User' : 'Assistant'}: ${t.Message.trim()}`);
1311
+ let total = lines.reduce((sum, line) => sum + line.length + 1, 0);
1312
+ while (lines.length > 0 && total > MAX_PRIOR_TRANSCRIPT_CHARS) {
1313
+ const dropped = lines.shift();
1314
+ total -= dropped.length + 1;
1315
+ }
1316
+ return lines;
1317
+ }
1318
+ /**
1319
+ * Folds prior session-channel rows into a `{ channelName: stateJson }` map, skipping empty
1320
+ * states and enforcing the {@link MAX_CHANNEL_STATE_CHARS} cap both per-state and on the
1321
+ * accumulated total (oversized states are dropped with a log note; the rest survive).
1322
+ */
1323
+ collectChannelStates(rows, lastSessionId) {
1324
+ const states = {};
1325
+ let totalChars = 0;
1326
+ for (const row of rows) {
1327
+ if (!row.Channel || !row.Config) {
1328
+ continue;
1329
+ }
1330
+ if (row.Config.length > MAX_CHANNEL_STATE_CHARS || totalChars + row.Config.length > MAX_CHANNEL_STATE_CHARS) {
1331
+ LogError(`StartRealtimeClientSession: dropped oversized '${row.Channel}' channel state from prior session ${lastSessionId} ` +
1332
+ `(${row.Config.length} chars; restore payload is capped at ${MAX_CHANNEL_STATE_CHARS}).`);
1333
+ continue;
1334
+ }
1335
+ states[row.Channel] = row.Config;
1336
+ totalChars += row.Config.length;
1337
+ }
1338
+ return states;
1339
+ }
1340
+ /**
1341
+ * Resolves the ENABLED {@link WHITEBOARD_ARTIFACT_TYPE_NAME} artifact type's id. Returns `null`
1342
+ * (logged) when the type seed is absent or disabled — the artifact-type seed is deployed
1343
+ * separately, so its absence is a graceful structured failure, never a throw.
1344
+ */
1345
+ async resolveWhiteboardArtifactTypeID(contextUser, provider) {
1346
+ const rv = RunView.FromMetadataProvider(provider);
1347
+ const result = await rv.RunView({
1348
+ EntityName: ARTIFACT_TYPE_ENTITY,
1349
+ ExtraFilter: `Name='${WHITEBOARD_ARTIFACT_TYPE_NAME}'`,
1350
+ Fields: ['ID', 'IsEnabled'],
1351
+ ResultType: 'simple',
1352
+ MaxRows: 1,
1353
+ }, contextUser);
1354
+ const row = result.Success ? result.Results?.[0] : undefined;
1355
+ if (!row || !row.IsEnabled) {
1356
+ LogError(`SaveSessionChannelArtifact: no enabled '${WHITEBOARD_ARTIFACT_TYPE_NAME}' artifact type found in ${ARTIFACT_TYPE_ENTITY} — ` +
1357
+ 'artifact not created (sync the artifact-type seed metadata to enable this).');
1358
+ return null;
1359
+ }
1360
+ return row.ID;
1361
+ }
1362
+ /**
1363
+ * Creates the user-owned artifact header + its v1 version for a channel-state save, then runs
1364
+ * the best-effort extras (conversation-history junction + session-channel `LastActiveAt`
1365
+ * stamp). Header/version save failures come back as structured failures; the extras NEVER
1366
+ * fail the save.
1367
+ */
1368
+ async createChannelArtifact(session, channelName, name, contentJson, typeID, contextUser, provider) {
1369
+ const artifact = await provider.GetEntityObject(ARTIFACT_ENTITY, contextUser);
1370
+ artifact.NewRecord();
1371
+ artifact.Name = name;
1372
+ artifact.Description = `Saved from realtime agent session ${session.ID} ('${channelName}' channel).`;
1373
+ artifact.TypeID = typeID;
1374
+ artifact.UserID = contextUser.ID;
1375
+ artifact.Visibility = 'Always';
1376
+ if (!(await artifact.Save())) {
1377
+ const message = `SaveSessionChannelArtifact: artifact save failed: ${artifact.LatestResult?.CompleteMessage ?? 'unknown error'}`;
1378
+ LogError(message);
1379
+ return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
1380
+ }
1381
+ const version = await provider.GetEntityObject(ARTIFACT_VERSION_ENTITY, contextUser);
1382
+ version.NewRecord();
1383
+ version.ArtifactID = artifact.ID;
1384
+ version.VersionNumber = 1;
1385
+ version.Content = contentJson;
1386
+ version.UserID = contextUser.ID;
1387
+ if (!(await version.Save())) {
1388
+ const message = `SaveSessionChannelArtifact: artifact version save failed: ${version.LatestResult?.CompleteMessage ?? 'unknown error'}`;
1389
+ LogError(message);
1390
+ return { Success: false, ErrorMessage: message, ArtifactID: artifact.ID, ConversationDetailLinked: false };
1391
+ }
1392
+ const linked = await this.linkVersionToLatestSessionDetail(session, version.ID, contextUser, provider);
1393
+ await this.stampSessionChannelLastActive(session.ID, channelName, contextUser, provider);
1394
+ return { Success: true, ArtifactID: artifact.ID, ArtifactVersionID: version.ID, ConversationDetailLinked: linked };
1395
+ }
1396
+ /**
1397
+ * Best-effort: links an artifact version into conversation history the way chat does — via a
1398
+ * `MJ: Conversation Detail Artifacts` junction row (Direction `Output`) against the LATEST
1399
+ * `Conversation Detail` stamped with this session's id (the transcript relay stamps
1400
+ * `AgentSessionID` on every persisted turn). Skips silently (returns `false`) when the session
1401
+ * has no conversation or no stamped detail exists yet; logs (and returns `false`) when the
1402
+ * lookup or junction save fails. Never throws.
1403
+ */
1404
+ async linkVersionToLatestSessionDetail(session, artifactVersionID, contextUser, provider) {
1405
+ if (!session.ConversationID) {
1406
+ return false;
1407
+ }
1408
+ try {
1409
+ const detailID = await this.findLatestSessionDetailID(session, contextUser, provider);
1410
+ if (!detailID) {
1411
+ return false; // no transcript turn persisted yet — nothing to anchor the artifact to
1412
+ }
1413
+ return await this.saveDetailArtifactJunction(detailID, artifactVersionID, contextUser, provider);
1414
+ }
1415
+ catch (error) {
1416
+ LogError(`SaveSessionChannelArtifact: conversation-history link failed: ${error.message}`);
1417
+ return false;
1418
+ }
1419
+ }
1420
+ /**
1421
+ * Best-effort: junction-links the artifacts a DELEGATED target-agent run produced into the
1422
+ * session's conversation history — closing the gap where voice-path artifacts were created
1423
+ * (`RealtimeClientSessionService.createDelegatedRunArtifacts`) but never reached
1424
+ * `MJ: Conversation Detail Artifacts`, leaving chat, session review, and resume carryover
1425
+ * blind to them.
1426
+ *
1427
+ * ANCHOR CHOICE: the junction needs a `Conversation Detail`. We anchor to the MOST RECENT
1428
+ * detail stamped with this session's id (the transcript relay stamps `AgentSessionID` on every
1429
+ * persisted turn) — the turn closest to the tool call that produced the artifact. When NO
1430
+ * stamped detail exists yet (the delegated run finished before the browser relayed any
1431
+ * transcript — the relay is asynchronous), we create a minimal HIDDEN anchor detail
1432
+ * (`Role: 'AI'`, `HiddenToUser: true`, stamped with the session id + conversation + user)
1433
+ * rather than dropping the link: `HiddenToUser` keeps it out of the visible chat thread and
1434
+ * out of review-mode captions (both filter hidden rows), while conversation-level artifact
1435
+ * queries (which scan junctions across ALL details) still surface the artifact. This is the
1436
+ * least-invasive correct anchor — no fake visible message, no orphaned artifact.
1437
+ *
1438
+ * Strictly best-effort: every failure path logs and returns; a relayed tool call NEVER fails
1439
+ * because history linking did.
1440
+ */
1441
+ async linkDelegatedArtifactsToConversation(session, artifacts, contextUser, provider) {
1442
+ if (!artifacts || artifacts.length === 0 || !session.ConversationID) {
1443
+ return;
1444
+ }
1445
+ try {
1446
+ const detailID = (await this.findLatestSessionDetailID(session, contextUser, provider)) ??
1447
+ (await this.createHiddenSessionAnchorDetail(session, contextUser, provider));
1448
+ if (!detailID) {
1449
+ return; // anchor unavailable — logged in the helpers
1450
+ }
1451
+ for (const artifact of artifacts) {
1452
+ await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, contextUser, provider);
1453
+ }
1454
+ }
1455
+ catch (error) {
1456
+ LogError(`ExecuteRealtimeSessionTool: delegated-artifact history link failed: ${error.message}`);
1457
+ }
1458
+ }
1459
+ /**
1460
+ * Finds the LATEST `Conversation Detail` stamped with this session's id (the transcript relay
1461
+ * stamps `AgentSessionID` on every persisted turn). Returns its id, or `null` when none exists.
1462
+ */
1463
+ async findLatestSessionDetailID(session, contextUser, provider) {
1464
+ const safeID = session.ID.replace(/'/g, "''");
1465
+ const rv = RunView.FromMetadataProvider(provider);
1466
+ const result = await rv.RunView({
1467
+ EntityName: CONVERSATION_DETAIL_ENTITY,
1468
+ ExtraFilter: `AgentSessionID='${safeID}'`,
1469
+ Fields: ['ID'],
1470
+ OrderBy: '__mj_CreatedAt DESC',
1471
+ ResultType: 'simple',
1472
+ MaxRows: 1,
1473
+ }, contextUser);
1474
+ const detail = result.Success ? result.Results?.[0] : undefined;
1475
+ return detail?.ID ?? null;
1476
+ }
1477
+ /**
1478
+ * Creates the minimal HIDDEN anchor `Conversation Detail` for artifact junction rows when no
1479
+ * session-stamped transcript turn exists yet (see the anchor-choice rationale on
1480
+ * {@link linkDelegatedArtifactsToConversation}). Returns the new detail's id, or `null`
1481
+ * (logged) when the save fails.
1482
+ */
1483
+ async createHiddenSessionAnchorDetail(session, contextUser, provider) {
1484
+ const detail = await provider.GetEntityObject(CONVERSATION_DETAIL_ENTITY, contextUser);
1485
+ detail.NewRecord();
1486
+ detail.ConversationID = session.ConversationID;
1487
+ detail.Role = 'AI';
1488
+ detail.HiddenToUser = true;
1489
+ detail.Message = 'Artifacts produced during a realtime session (system anchor).';
1490
+ detail.AgentSessionID = session.ID;
1491
+ detail.UserID = contextUser.ID;
1492
+ if (await detail.Save()) {
1493
+ return detail.ID;
1494
+ }
1495
+ LogError(`ExecuteRealtimeSessionTool: hidden anchor detail save failed for session ${session.ID}: ` +
1496
+ `${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1497
+ return null;
1498
+ }
1499
+ /**
1500
+ * Saves one `MJ: Conversation Detail Artifacts` junction row (Direction `Output`) linking an
1501
+ * artifact version to a conversation detail. Returns the boolean save result (logged on failure).
1502
+ */
1503
+ async saveDetailArtifactJunction(conversationDetailID, artifactVersionID, contextUser, provider) {
1504
+ const junction = await provider.GetEntityObject(CONVERSATION_DETAIL_ARTIFACT_ENTITY, contextUser);
1505
+ junction.NewRecord();
1506
+ junction.ConversationDetailID = conversationDetailID;
1507
+ junction.ArtifactVersionID = artifactVersionID;
1508
+ junction.Direction = 'Output';
1509
+ const saved = await junction.Save();
1510
+ if (!saved) {
1511
+ LogError(`RealtimeClientSessionResolver: artifact junction save failed for detail ${conversationDetailID} / version ${artifactVersionID}: ` +
1512
+ `${junction.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1513
+ }
1514
+ return saved;
1515
+ }
1516
+ /**
1517
+ * Best-effort: stamps `LastActiveAt` on the session-channel row for `(session, channelName)`
1518
+ * when one exists — saving an artifact IS channel activity. Missing channel definition or
1519
+ * session-channel row is a silent no-op; failures log and never throw.
1520
+ */
1521
+ async stampSessionChannelLastActive(agentSessionID, channelName, contextUser, provider) {
1522
+ try {
1523
+ const channelID = await this.resolveChannelID(channelName, contextUser, provider);
1524
+ if (!channelID) {
1525
+ return;
1526
+ }
1527
+ const rv = RunView.FromMetadataProvider(provider);
1528
+ const existing = await rv.RunView({
1529
+ EntityName: SESSION_CHANNEL_ENTITY,
1530
+ ExtraFilter: `AgentSessionID='${agentSessionID}' AND ChannelID='${channelID}'`,
1531
+ ResultType: 'entity_object',
1532
+ MaxRows: 1,
1533
+ }, contextUser);
1534
+ const row = existing.Success ? existing.Results?.[0] : undefined;
1535
+ if (!row) {
1536
+ return;
1537
+ }
1538
+ row.LastActiveAt = new Date();
1539
+ if (!(await row.Save())) {
1540
+ LogError(`SaveSessionChannelArtifact: LastActiveAt stamp failed for session ${agentSessionID} / channel ${channelID}: ` +
1541
+ `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1542
+ }
1543
+ }
1544
+ catch (error) {
1545
+ LogError(`SaveSessionChannelArtifact: LastActiveAt stamp failed: ${error.message}`);
1546
+ }
1547
+ }
1548
+ /**
1549
+ * Tolerantly parses + validates client-declared UI tool definitions (see the SECURITY NOTE on
1550
+ * {@link StartRealtimeClientSession}). Never throws — any rejection logs and returns
1551
+ * `undefined` (the session simply minted without client tools):
1552
+ * - non-JSON / non-array payloads are rejected wholesale;
1553
+ * - payloads larger than {@link MAX_CLIENT_TOOLS_JSON_CHARS} are rejected wholesale;
1554
+ * - more than {@link MAX_CLIENT_TOOLS} declarations are rejected wholesale (a legitimate
1555
+ * client never sends that many — a flood is a misbehaving caller, not a trim candidate);
1556
+ * - individual entries failing the shape check (`Name`/`Description` non-empty strings,
1557
+ * `ParametersSchema` a plain object) are skipped, the rest survive.
1558
+ */
1559
+ parseClientTools(clientToolsJson) {
1560
+ if (!clientToolsJson) {
1561
+ return undefined;
1562
+ }
1563
+ if (clientToolsJson.length > MAX_CLIENT_TOOLS_JSON_CHARS) {
1564
+ LogError(`StartRealtimeClientSession: clientToolsJson rejected — ${clientToolsJson.length} chars exceeds the ${MAX_CLIENT_TOOLS_JSON_CHARS} cap.`);
1565
+ return undefined;
1566
+ }
1567
+ let parsed;
1568
+ try {
1569
+ parsed = JSON.parse(clientToolsJson);
1570
+ }
1571
+ catch {
1572
+ LogError('StartRealtimeClientSession: clientToolsJson rejected — not valid JSON.');
1573
+ return undefined;
1574
+ }
1575
+ if (!Array.isArray(parsed)) {
1576
+ LogError('StartRealtimeClientSession: clientToolsJson rejected — expected a JSON array of tool definitions.');
1577
+ return undefined;
1578
+ }
1579
+ if (parsed.length > MAX_CLIENT_TOOLS) {
1580
+ LogError(`StartRealtimeClientSession: clientToolsJson rejected — ${parsed.length} tools exceeds the ${MAX_CLIENT_TOOLS} cap.`);
1581
+ return undefined;
1582
+ }
1583
+ const valid = parsed.filter((t) => this.isValidClientTool(t));
1584
+ if (valid.length < parsed.length) {
1585
+ LogError(`StartRealtimeClientSession: skipped ${parsed.length - valid.length} malformed client tool definition(s).`);
1586
+ }
1587
+ return valid.length > 0 ? valid : undefined;
1588
+ }
1589
+ /** Shape check for one client tool declaration: Name/Description non-empty strings, ParametersSchema a plain object. */
1590
+ isValidClientTool(candidate) {
1591
+ if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
1592
+ return false;
1593
+ }
1594
+ const tool = candidate;
1595
+ return (typeof tool.Name === 'string' && tool.Name.trim().length > 0 && tool.Name.length <= 128 &&
1596
+ typeof tool.Description === 'string' && tool.Description.trim().length > 0 &&
1597
+ tool.ParametersSchema !== null && typeof tool.ParametersSchema === 'object' && !Array.isArray(tool.ParametersSchema));
1598
+ }
1599
+ /**
1600
+ * Parses the session's persisted config, returning the authoritative `targetAgentID` plus any
1601
+ * observability run ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed
1602
+ * or carries no target — a relay cannot proceed without a target.
1603
+ */
1604
+ readSessionConfig(session) {
1605
+ const raw = session.Config_;
1606
+ if (raw) {
1607
+ try {
1608
+ const parsed = JSON.parse(raw);
1609
+ if (typeof parsed.targetAgentID === 'string' && parsed.targetAgentID.length > 0) {
1610
+ return {
1611
+ targetAgentID: parsed.targetAgentID,
1612
+ coAgentRunID: typeof parsed.coAgentRunID === 'string' ? parsed.coAgentRunID : undefined,
1613
+ promptRunID: typeof parsed.promptRunID === 'string' ? parsed.promptRunID : undefined,
1614
+ coAgentRunStepID: typeof parsed.coAgentRunStepID === 'string' ? parsed.coAgentRunStepID : undefined,
1615
+ pendingFeedbackRunID: typeof parsed.pendingFeedbackRunID === 'string' ? parsed.pendingFeedbackRunID : undefined,
1616
+ };
1617
+ }
1618
+ }
1619
+ catch {
1620
+ /* fall through to the error below */
1621
+ }
1622
+ }
1623
+ throw new Error(`Realtime session ${session.ID} has no target agent configured`);
1624
+ }
1625
+ /**
1626
+ * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
1627
+ * conversation, the mapped role, the turn text, the session id, and the owning user.
1628
+ *
1629
+ * @returns The boolean save result (logs `CompleteMessage` on failure).
1630
+ */
1631
+ async persistTranscriptTurn(session, role, text, contextUser, provider) {
1632
+ const detail = await provider.GetEntityObject(CONVERSATION_DETAIL_ENTITY, contextUser);
1633
+ detail.NewRecord();
1634
+ detail.ConversationID = session.ConversationID;
1635
+ detail.Role = this.mapTranscriptRole(role);
1636
+ detail.Message = text;
1637
+ detail.AgentSessionID = session.ID;
1638
+ detail.UserID = contextUser.ID;
1639
+ const saved = await detail.Save();
1640
+ if (!saved) {
1641
+ LogError(`RealtimeClientSessionResolver.persistTranscriptTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1642
+ }
1643
+ return saved;
1644
+ }
1645
+ /**
1646
+ * CORRECTION persistence (the client transcript's `ReplacesPrevious` marker — e.g.
1647
+ * ElevenLabs' post-barge-in `agent_response_correction`): UPDATES the session's most
1648
+ * recently persisted turn of the same role IN PLACE with the corrected text, instead of
1649
+ * appending a near-duplicate. Falls back to a plain insert when no prior turn exists
1650
+ * (the superseded turn may have failed to persist) — a correction is never dropped.
1651
+ */
1652
+ async replacePreviousTranscriptTurn(session, role, text, contextUser, provider) {
1653
+ const mappedRole = this.mapTranscriptRole(role);
1654
+ const rv = RunView.FromMetadataProvider(provider);
1655
+ const result = await rv.RunView({
1656
+ EntityName: CONVERSATION_DETAIL_ENTITY,
1657
+ ExtraFilter: `AgentSessionID='${session.ID}' AND Role='${mappedRole}'`,
1658
+ OrderBy: '__mj_CreatedAt DESC',
1659
+ MaxRows: 1,
1660
+ ResultType: 'entity_object',
1661
+ }, contextUser);
1662
+ const previous = result.Success ? (result.Results?.[0] ?? null) : null;
1663
+ if (!previous) {
1664
+ return this.persistTranscriptTurn(session, role, text, contextUser, provider);
1665
+ }
1666
+ previous.Message = text;
1667
+ const saved = await previous.Save();
1668
+ if (!saved) {
1669
+ LogError(`RealtimeClientSessionResolver.replacePreviousTranscriptTurn save failed: ${previous.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1670
+ }
1671
+ return saved;
1672
+ }
1673
+ /**
1674
+ * Maps a transport-level transcript role (`'user'` / `'assistant'`) onto the
1675
+ * `Conversation Detail` entity's `Role` union (`'User'` / `'AI'`). Anything not the user is
1676
+ * treated as the assistant/AI side.
1677
+ */
1678
+ mapTranscriptRole(role) {
1679
+ return role.trim().toLowerCase() === 'user' ? 'User' : 'AI';
1680
+ }
1681
+ };
1682
+ __decorate([
1683
+ Mutation(() => StartRealtimeClientSessionResult),
1684
+ __param(0, Arg('targetAgentId', () => String, { nullable: true })),
1685
+ __param(1, Ctx()),
1686
+ __param(2, Arg('conversationId', () => String, { nullable: true })),
1687
+ __param(3, Arg('lastSessionId', () => String, { nullable: true })),
1688
+ __param(4, Arg('preferredModelId', () => String, { nullable: true })),
1689
+ __param(5, Arg('clientToolsJson', () => String, { nullable: true })),
1690
+ __param(6, Arg('coAgentId', () => String, { nullable: true })),
1691
+ __param(7, Arg('configOverridesJson', () => String, { nullable: true })),
1692
+ __metadata("design:type", Function),
1693
+ __metadata("design:paramtypes", [String, Object, String, String, String, String, String, String]),
1694
+ __metadata("design:returntype", Promise)
1695
+ ], RealtimeClientSessionResolver.prototype, "StartRealtimeClientSession", null);
1696
+ __decorate([
1697
+ Mutation(() => String),
1698
+ __param(0, Arg('agentSessionId', () => String)),
1699
+ __param(1, Arg('callId', () => String)),
1700
+ __param(2, Arg('toolName', () => String)),
1701
+ __param(3, Arg('argsJson', () => String)),
1702
+ __param(4, Ctx()),
1703
+ __param(5, PubSub()),
1704
+ __metadata("design:type", Function),
1705
+ __metadata("design:paramtypes", [String, String, String, String, Object, PubSubEngine]),
1706
+ __metadata("design:returntype", Promise)
1707
+ ], RealtimeClientSessionResolver.prototype, "ExecuteRealtimeSessionTool", null);
1708
+ __decorate([
1709
+ Mutation(() => Boolean),
1710
+ __param(0, Arg('agentSessionId', () => String)),
1711
+ __param(1, Arg('role', () => String)),
1712
+ __param(2, Arg('text', () => String)),
1713
+ __param(3, Ctx()),
1714
+ __param(4, Arg('replacesPrevious', () => Boolean, { nullable: true })),
1715
+ __metadata("design:type", Function),
1716
+ __metadata("design:paramtypes", [String, String, String, Object, Boolean]),
1717
+ __metadata("design:returntype", Promise)
1718
+ ], RealtimeClientSessionResolver.prototype, "RelayRealtimeTranscript", null);
1719
+ __decorate([
1720
+ Mutation(() => Boolean),
1721
+ __param(0, Arg('agentSessionId', () => String)),
1722
+ __param(1, Arg('toolName', () => String)),
1723
+ __param(2, Ctx()),
1724
+ __param(3, Arg('argsJson', () => String, { nullable: true })),
1725
+ __param(4, Arg('resultJson', () => String, { nullable: true })),
1726
+ __metadata("design:type", Function),
1727
+ __metadata("design:paramtypes", [String, String, Object, String, String]),
1728
+ __metadata("design:returntype", Promise)
1729
+ ], RealtimeClientSessionResolver.prototype, "RelayRealtimeToolTurn", null);
1730
+ __decorate([
1731
+ Mutation(() => CancelRealtimeSessionToolResult),
1732
+ __param(0, Arg('agentSessionId', () => String)),
1733
+ __param(1, Ctx()),
1734
+ __param(2, Arg('callId', () => String, { nullable: true })),
1735
+ __metadata("design:type", Function),
1736
+ __metadata("design:paramtypes", [String, Object, String]),
1737
+ __metadata("design:returntype", Promise)
1738
+ ], RealtimeClientSessionResolver.prototype, "CancelRealtimeSessionTool", null);
1739
+ __decorate([
1740
+ Mutation(() => Boolean),
1741
+ __param(0, Arg('agentSessionId', () => String)),
1742
+ __param(1, Arg('inputTokens', () => Int)),
1743
+ __param(2, Arg('outputTokens', () => Int)),
1744
+ __param(3, Ctx()),
1745
+ __metadata("design:type", Function),
1746
+ __metadata("design:paramtypes", [String, Number, Number, Object]),
1747
+ __metadata("design:returntype", Promise)
1748
+ ], RealtimeClientSessionResolver.prototype, "RelayRealtimeUsage", null);
1749
+ __decorate([
1750
+ Mutation(() => Boolean),
1751
+ __param(0, Arg('agentSessionId', () => String)),
1752
+ __param(1, Arg('channelName', () => String)),
1753
+ __param(2, Arg('stateJson', () => String)),
1754
+ __param(3, Ctx()),
1755
+ __metadata("design:type", Function),
1756
+ __metadata("design:paramtypes", [String, String, String, Object]),
1757
+ __metadata("design:returntype", Promise)
1758
+ ], RealtimeClientSessionResolver.prototype, "SaveSessionChannelState", null);
1759
+ __decorate([
1760
+ Mutation(() => SaveSessionChannelArtifactResult),
1761
+ __param(0, Arg('agentSessionId', () => String)),
1762
+ __param(1, Arg('channelName', () => String)),
1763
+ __param(2, Arg('name', () => String)),
1764
+ __param(3, Arg('contentJson', () => String)),
1765
+ __param(4, Ctx()),
1766
+ __metadata("design:type", Function),
1767
+ __metadata("design:paramtypes", [String, String, String, String, Object]),
1768
+ __metadata("design:returntype", Promise)
1769
+ ], RealtimeClientSessionResolver.prototype, "SaveSessionChannelArtifact", null);
1770
+ RealtimeClientSessionResolver = __decorate([
1771
+ Resolver()
1772
+ ], RealtimeClientSessionResolver);
1773
+ export { RealtimeClientSessionResolver };
1774
+ //# sourceMappingURL=RealtimeClientSessionResolver.js.map