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