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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/generated/graphql-schemas/__mj.d.ts +1010 -0
  3. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  4. package/dist/generated/graphql-schemas/__mj.js +5178 -3
  5. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  6. package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
  7. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  8. package/dist/generic/FireAndForgetHeartbeat.js +16 -2
  9. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  10. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
  11. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
  12. package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
  13. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
  14. package/dist/generic/PushStatusResolver.d.ts +38 -0
  15. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  16. package/dist/generic/PushStatusResolver.js +84 -1
  17. package/dist/generic/PushStatusResolver.js.map +1 -1
  18. package/dist/generic/ResolverBase.d.ts +13 -1
  19. package/dist/generic/ResolverBase.d.ts.map +1 -1
  20. package/dist/generic/ResolverBase.js +49 -47
  21. package/dist/generic/ResolverBase.js.map +1 -1
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +90 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
  27. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
  28. package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
  29. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
  30. package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
  31. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
  32. package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
  33. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
  34. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
  35. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
  36. package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
  37. package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
  38. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  39. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
  40. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  41. package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
  42. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  43. package/dist/resolvers/MergeRecordsResolver.js +16 -2
  44. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  45. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
  46. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
  48. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  49. package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
  50. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RunAIAgentResolver.js +23 -153
  52. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  53. package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
  54. package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
  55. package/dist/resolvers/RunDecisionResolver.js +396 -0
  56. package/dist/resolvers/RunDecisionResolver.js.map +1 -0
  57. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  58. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  59. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  60. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  61. package/package.json +94 -92
  62. package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
  63. package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
  64. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  65. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
  66. package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
  67. package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
  68. package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
  69. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
  70. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
  71. package/src/__tests__/RunDecisionResolver.test.ts +699 -0
  72. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
  73. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  74. package/src/__tests__/conversationRunEvents.test.ts +399 -0
  75. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
  76. package/src/__tests__/pushStatusFanOut.test.ts +206 -0
  77. package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
  78. package/src/generated/graphql-schemas/__mj.ts +3637 -4
  79. package/src/generic/FireAndForgetHeartbeat.ts +16 -2
  80. package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
  81. package/src/generic/PushStatusResolver.ts +97 -1
  82. package/src/generic/ResolverBase.ts +49 -49
  83. package/src/index.ts +102 -2
  84. package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
  85. package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
  86. package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
  87. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
  88. package/src/resolvers/MergeRecordsResolver.ts +14 -2
  89. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  90. package/src/resolvers/RunAIAgentResolver.ts +28 -174
  91. package/src/resolvers/RunDecisionResolver.ts +435 -0
  92. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  93. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
@@ -0,0 +1,699 @@
1
+ // type-graphql decorators call `Reflect.getMetadata`, which only exists once this polyfill is
2
+ // loaded. MUST precede any import that pulls in the resolver file.
3
+ import 'reflect-metadata';
4
+
5
+ import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest';
6
+
7
+ /** The slice of `APIKeyEngine.Authorize` that ResolverBase.CheckAPIKeyScopeAuthorization calls. */
8
+ type AuthorizeFn = (
9
+ apiKeyHash: string,
10
+ application: string,
11
+ scopePath: string,
12
+ resource: string,
13
+ contextUser: UserInfo,
14
+ usageDetails: { endpoint: string; method: string },
15
+ options?: { skipLogging?: boolean; actingContext?: UserInfo['APIKeyActingContext'] }
16
+ ) => Promise<{ Allowed: boolean; Reason: string }>;
17
+
18
+ const { mockAuthorize } = vi.hoisted(() => ({ mockAuthorize: vi.fn<AuthorizeFn>() }));
19
+
20
+ // As in RunAIPromptResolver.simplePromptModel.test.ts: vitest's esbuild transform does not apply
21
+ // `emitDecoratorMetadata`, so the decorators are no-ops and the resolvers run as plain classes.
22
+ // `AuthorizationError` is a real Error so ResolverBase's denial keeps its message.
23
+ vi.mock('type-graphql', () => {
24
+ const noopDecorator = () => () => undefined;
25
+ return {
26
+ Resolver: noopDecorator, Mutation: noopDecorator, Query: noopDecorator, Subscription: noopDecorator,
27
+ ObjectType: noopDecorator, InputType: noopDecorator, Field: noopDecorator, Arg: noopDecorator,
28
+ Args: noopDecorator, Ctx: noopDecorator, PubSub: noopDecorator, Root: noopDecorator,
29
+ Directive: noopDecorator, Authorized: noopDecorator, UseMiddleware: noopDecorator,
30
+ createUnionType: () => class {},
31
+ AuthorizationError: class AuthorizationError extends Error {
32
+ constructor(message?: string) {
33
+ super(message);
34
+ this.name = 'AuthorizationError';
35
+ }
36
+ },
37
+ Float: class {}, Int: class {}, ID: class {},
38
+ };
39
+ });
40
+
41
+ // Only the API-key engine is replaced. ResolverBase.CheckAPIKeyScopeAuthorization itself is the
42
+ // production code, so the authorization tests below exercise the real gate for both resolvers.
43
+ vi.mock('@memberjunction/api-keys', async (importOriginal) => {
44
+ const actual = await importOriginal<Record<string, unknown>>();
45
+ return { ...actual, GetAPIKeyEngine: () => ({ Authorize: mockAuthorize }) };
46
+ });
47
+
48
+ import { EntityInfo, UserInfo } from '@memberjunction/core';
49
+ import { MJAIModelTypeEntity, MJAIPromptRunEntity } from '@memberjunction/core-entities';
50
+ import { UserCache } from '@memberjunction/generic-database-provider';
51
+ import { AIEngine } from '@memberjunction/aiengine';
52
+ import { MJAIPromptEntityExtended } from '@memberjunction/ai-core-plus';
53
+ import { AIDecisionRunner, type AIDecisionParams, type AIDecisionRunResult } from '@memberjunction/ai-prompts';
54
+ import { DecisionResult, type DecisionAnswer, type DecisionQuestion } from '@memberjunction/ai';
55
+ import { RunDecisionResolver } from '../resolvers/RunDecisionResolver.js';
56
+ import { RunAIPromptResolver } from '../resolvers/RunAIPromptResolver.js';
57
+ import type { AppContext, UserPayload } from '../types.js';
58
+
59
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * A minimal entity definition with the named fields, so the fixtures below are real entity objects
63
+ * rather than casts: the ID is the primary key, and every field reads back what was hydrated.
64
+ */
65
+ function entityInfo(name: string, fieldNames: string[]): EntityInfo {
66
+ const entityId = `entity-${name}`;
67
+ return new EntityInfo({
68
+ ID: entityId, Name: name, SchemaName: '__mj', BaseTable: name, BaseView: `vw${name}`,
69
+ Fields: fieldNames.map((field, index) => ({
70
+ ID: `${entityId}-${field}`, EntityID: entityId, Sequence: index + 1, Name: field, Entity: name,
71
+ Type: field === 'ID' ? 'uniqueidentifier' : 'nvarchar', IsPrimaryKey: field === 'ID',
72
+ })),
73
+ });
74
+ }
75
+
76
+ const PROMPT_ENTITY = entityInfo('MJ: AI Prompts', ['ID', 'Name', 'Status', 'AIModelTypeID']);
77
+ const MODEL_TYPE_ENTITY = entityInfo('MJ: AI Model Types', ['ID', 'Name']);
78
+ const PROMPT_RUN_ENTITY = entityInfo('MJ: AI Prompt Runs', ['ID']);
79
+
80
+ const DECISION_TYPE_ID = 'type-decision';
81
+ const LLM_TYPE_ID = 'type-llm';
82
+
83
+ /** The prompt fields the resolver reads. */
84
+ type PromptFields = Pick<MJAIPromptEntityExtended, 'ID' | 'Name' | 'Status' | 'AIModelTypeID'>;
85
+
86
+ const makePrompt = (over: Partial<PromptFields>): MJAIPromptEntityExtended => {
87
+ const fields: PromptFields = {
88
+ ID: 'prompt-default', Name: 'Default Decision', Status: 'Active', AIModelTypeID: DECISION_TYPE_ID,
89
+ ...over,
90
+ };
91
+ const prompt = new MJAIPromptEntityExtended(PROMPT_ENTITY);
92
+ prompt.Hydrate(fields);
93
+ return prompt;
94
+ };
95
+
96
+ const makeModelType = (fields: Pick<MJAIModelTypeEntity, 'ID' | 'Name'>): MJAIModelTypeEntity => {
97
+ const modelType = new MJAIModelTypeEntity(MODEL_TYPE_ENTITY);
98
+ modelType.Hydrate(fields);
99
+ return modelType;
100
+ };
101
+
102
+ const DEFAULT_PROMPT = makePrompt({});
103
+ const ROUTING_PROMPT = makePrompt({ ID: 'prompt-routing', Name: 'Route Message' });
104
+ const CHAT_PROMPT = makePrompt({ ID: 'prompt-chat', Name: 'Summarize Text', AIModelTypeID: LLM_TYPE_ID });
105
+ const UNTYPED_PROMPT = makePrompt({ ID: 'prompt-untyped', Name: 'Untyped Prompt', AIModelTypeID: null });
106
+ const INACTIVE_PROMPT = makePrompt({ ID: 'prompt-inactive', Name: 'Retired Decision', Status: 'Disabled' });
107
+
108
+ const MODEL_TYPES = [
109
+ makeModelType({ ID: DECISION_TYPE_ID, Name: 'Decision' }),
110
+ makeModelType({ ID: LLM_TYPE_ID, Name: 'LLM' }),
111
+ ];
112
+
113
+ /**
114
+ * Gives AIEngine a fixed prompt set and the two model types the tests use; its load does nothing.
115
+ * @returns The spy on the engine's load.
116
+ */
117
+ function stubEngine(prompts: MJAIPromptEntityExtended[]): MockInstance<AIEngine['Config']> {
118
+ vi.spyOn(AIEngine.prototype, 'Prompts', 'get').mockReturnValue(prompts);
119
+ vi.spyOn(AIEngine.prototype, 'ModelTypes', 'get').mockReturnValue(MODEL_TYPES);
120
+ return vi.spyOn(AIEngine.prototype, 'Config').mockResolvedValue(undefined);
121
+ }
122
+
123
+ const QUESTIONS: Record<string, DecisionQuestion> = {
124
+ route: {
125
+ Kind: 'Choice',
126
+ Instructions: 'Which agent should answer this message?',
127
+ Options: [
128
+ { Value: 'sage', Description: 'General questions about the product' },
129
+ { Value: 'analyst', Description: 'Questions about data and reports' },
130
+ ],
131
+ },
132
+ duplicate: { Kind: 'Likelihood', Instructions: 'Does this message repeat an earlier one?' },
133
+ };
134
+
135
+ const ANSWERS: Record<string, DecisionAnswer> = {
136
+ route: { Kind: 'Choice', Value: 'analyst', Confidence: 0.9, Probabilities: { sage: 0.1, analyst: 0.9 } },
137
+ duplicate: { Kind: 'Likelihood', Probability: 0.2 },
138
+ };
139
+
140
+ const PROMPT_RUN = new MJAIPromptRunEntity(PROMPT_RUN_ENTITY);
141
+ PROMPT_RUN.Hydrate({ ID: 'run-1' });
142
+
143
+ /** What the Jev driver returned: its answers, and the dated model OpenRouter reported. */
144
+ const jevDecision = (): DecisionResult => {
145
+ const decided = new DecisionResult(true, new Date(), new Date());
146
+ decided.Answers = ANSWERS;
147
+ decided.ResolvedModel = 'typesafe/jev-1.13-20260917';
148
+ return decided;
149
+ };
150
+
151
+ const successResult = (): AIDecisionRunResult => ({
152
+ success: true,
153
+ Answers: ANSWERS,
154
+ promptRun: PROMPT_RUN,
155
+ modelInfo: { modelId: 'model-jev', modelName: 'Jev' },
156
+ DecisionResult: jevDecision(),
157
+ });
158
+
159
+ const failedResult = (): AIDecisionRunResult => ({
160
+ success: false,
161
+ errorMessage: 'No Decision model has credentials available',
162
+ Answers: {},
163
+ promptRun: PROMPT_RUN,
164
+ modelInfo: { modelId: 'model-jev', modelName: 'Jev' },
165
+ });
166
+
167
+ const makeUser = (fields: Pick<UserInfo, 'ID' | 'Name' | 'Email'>): UserInfo => Object.assign(new UserInfo(), fields);
168
+
169
+ const USER = makeUser({ ID: 'user-1', Name: 'Test User', Email: 'test@example.com' });
170
+ const SYSTEM_USER = makeUser({ ID: 'user-system', Name: 'System', Email: 'system@example.com' });
171
+
172
+ /** A session authenticated by JWT: no API key, so no scope check. */
173
+ const sessionPayload = (): UserPayload => ({ email: USER.Email, userRecord: USER, sessionId: 'session-1' });
174
+ /** A session authenticated by an MJ API key: scope-checked. */
175
+ const apiKeyPayload = (): UserPayload => ({ ...sessionPayload(), apiKeyId: 'key-1', apiKeyHash: 'hash-1' });
176
+
177
+ /** The context both resolvers read: only `userPayload` (and `providers`, for RunAIPrompt). */
178
+ function context(userPayload: UserPayload): AppContext {
179
+ const partial: Pick<AppContext, 'userPayload' | 'providers'> = { userPayload, providers: [] };
180
+ return partial as AppContext;
181
+ }
182
+
183
+ interface RunArgs {
184
+ State?: string;
185
+ Questions?: string;
186
+ PromptID?: string;
187
+ PromptName?: string;
188
+ TimeoutMS?: number;
189
+ Payload?: UserPayload;
190
+ }
191
+
192
+ const runDecision = (args: RunArgs = {}) =>
193
+ new RunDecisionResolver().RunDecision(
194
+ args.State ?? 'A customer asks why the invoice total changed this month.',
195
+ args.Questions ?? JSON.stringify(QUESTIONS),
196
+ context(args.Payload ?? sessionPayload()),
197
+ args.PromptID,
198
+ args.PromptName,
199
+ args.TimeoutMS
200
+ );
201
+
202
+ /** The scope-check calls, without the `full_access` fast-path probe that precedes each one. */
203
+ const scopeCalls = () => mockAuthorize.mock.calls.filter(call => call[2] !== 'full_access');
204
+
205
+ /** The resources the scope checks were made against, in order. */
206
+ const checkedResources = () => scopeCalls().map(call => call[3]);
207
+
208
+ /**
209
+ * An API key allowed `prompt:execute` on every prompt except the resources given, as a broad allow
210
+ * plus a Deny rule on each would have it. It has no `full_access`.
211
+ */
212
+ const allowPromptsExcept = (...denied: string[]) =>
213
+ mockAuthorize.mockImplementation(async (_hash, _app, scope, resource) => {
214
+ const allowed = scope === 'prompt:execute' && !denied.includes(resource);
215
+ return { Allowed: allowed, Reason: allowed ? 'Allowed' : 'Denied by a rule' };
216
+ });
217
+
218
+ /** The one message for a prompt, named by ID, that cannot run. */
219
+ const unavailableById = (id: string) => `AI Prompt with ID ${id} was not found or is not an active Decision prompt`;
220
+
221
+ let executeDecision: MockInstance<AIDecisionRunner['ExecuteDecision']>;
222
+ let engineLoad: MockInstance<AIEngine['Config']>;
223
+ const sentParams = (): AIDecisionParams => executeDecision.mock.calls[0][0];
224
+
225
+ beforeEach(() => {
226
+ vi.restoreAllMocks();
227
+ mockAuthorize.mockReset();
228
+ engineLoad = stubEngine([DEFAULT_PROMPT, ROUTING_PROMPT, CHAT_PROMPT, UNTYPED_PROMPT, INACTIVE_PROMPT]);
229
+ vi.spyOn(UserCache.prototype, 'GetSystemUser').mockReturnValue(SYSTEM_USER);
230
+ executeDecision = vi.spyOn(AIDecisionRunner.prototype, 'ExecuteDecision').mockResolvedValue(successResult());
231
+ });
232
+
233
+ // ─── Prompt resolution ───────────────────────────────────────────────────────
234
+
235
+ describe('RunDecision: prompt resolution', () => {
236
+ it('uses the Default Decision prompt when the caller names none', async () => {
237
+ const result = await runDecision();
238
+
239
+ expect(result.success).toBe(true);
240
+ expect(sentParams().prompt).toBe(DEFAULT_PROMPT);
241
+ });
242
+
243
+ it('resolves a prompt by ID', async () => {
244
+ const result = await runDecision({ PromptID: 'PROMPT-ROUTING' });
245
+
246
+ expect(result.success).toBe(true);
247
+ expect(sentParams().prompt).toBe(ROUTING_PROMPT);
248
+ });
249
+
250
+ it('resolves a prompt by name, ignoring case and surrounding space', async () => {
251
+ const result = await runDecision({ PromptName: ' route message ' });
252
+
253
+ expect(result.success).toBe(true);
254
+ expect(sentParams().prompt).toBe(ROUTING_PROMPT);
255
+ });
256
+
257
+ it('prefers the ID when both an ID and a name are given', async () => {
258
+ await runDecision({ PromptID: ROUTING_PROMPT.ID, PromptName: 'Default Decision' });
259
+
260
+ expect(sentParams().prompt).toBe(ROUTING_PROMPT);
261
+ });
262
+
263
+ it('fails without running when the prompt is not found', async () => {
264
+ const byId = await runDecision({ PromptID: 'prompt-missing' });
265
+ const byName = await runDecision({ PromptName: 'No Such Prompt' });
266
+
267
+ expect(byId).toMatchObject({ success: false, errorMessage: unavailableById('prompt-missing') });
268
+ expect(byName).toMatchObject({ success: false, errorMessage: "AI Prompt 'No Such Prompt' was not found or is not an active Decision prompt" });
269
+ expect(executeDecision).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it('rejects a prompt whose AI model type is not Decision', async () => {
273
+ const result = await runDecision({ PromptID: CHAT_PROMPT.ID });
274
+
275
+ expect(result).toMatchObject({ success: false, errorMessage: unavailableById(CHAT_PROMPT.ID) });
276
+ expect(executeDecision).not.toHaveBeenCalled();
277
+ });
278
+
279
+ it('rejects a prompt with no AI model type', async () => {
280
+ const result = await runDecision({ PromptName: 'Untyped Prompt' });
281
+
282
+ expect(result).toMatchObject({ success: false, errorMessage: "AI Prompt 'Untyped Prompt' was not found or is not an active Decision prompt" });
283
+ expect(executeDecision).not.toHaveBeenCalled();
284
+ });
285
+
286
+ it('rejects an inactive prompt', async () => {
287
+ const result = await runDecision({ PromptID: INACTIVE_PROMPT.ID });
288
+
289
+ expect(result).toMatchObject({ success: false, errorMessage: unavailableById(INACTIVE_PROMPT.ID) });
290
+ expect(executeDecision).not.toHaveBeenCalled();
291
+ });
292
+
293
+ it('gives the same message whether a prompt is missing, not Decision-typed or inactive, naming nothing the caller did not send', async () => {
294
+ const refusals = [
295
+ { ID: 'prompt-missing', Result: await runDecision({ PromptID: 'prompt-missing' }) },
296
+ { ID: CHAT_PROMPT.ID, Result: await runDecision({ PromptID: CHAT_PROMPT.ID }) },
297
+ { ID: UNTYPED_PROMPT.ID, Result: await runDecision({ PromptID: UNTYPED_PROMPT.ID }) },
298
+ { ID: INACTIVE_PROMPT.ID, Result: await runDecision({ PromptID: INACTIVE_PROMPT.ID }) },
299
+ ];
300
+
301
+ for (const refusal of refusals) {
302
+ expect(refusal.Result.errorMessage).toBe(unavailableById(refusal.ID));
303
+ }
304
+ const messages = refusals.map(refusal => refusal.Result.errorMessage ?? '').join('\n');
305
+ for (const hidden of [CHAT_PROMPT.Name, UNTYPED_PROMPT.Name, INACTIVE_PROMPT.Name, INACTIVE_PROMPT.Status]) {
306
+ expect(messages).not.toContain(hidden);
307
+ }
308
+ });
309
+ });
310
+
311
+ // ─── Scope-limited sessions ──────────────────────────────────────────────────
312
+
313
+ describe('RunDecision: scope-limited sessions', () => {
314
+ /** A session whose user is a magic-link guest or a resource-scoped session. */
315
+ const scopeLimitedPayload = (limit: Pick<UserInfo, 'IsMagicLinkAnonymous' | 'MagicLinkScope'>): UserPayload => {
316
+ const user = Object.assign(makeUser({ ID: 'user-guest', Name: 'Guest', Email: 'guest@example.com' }), limit);
317
+ return { email: user.Email, userRecord: user, sessionId: 'session-guest' };
318
+ };
319
+
320
+ it.each([
321
+ ['an anonymous magic-link guest', { IsMagicLinkAnonymous: true, MagicLinkScope: undefined }],
322
+ ['a resource-scoped session', { IsMagicLinkAnonymous: false, MagicLinkScope: { ResourceID: 'resource-1', ResourceType: 'Dashboards' } }],
323
+ ])('refuses %s before it loads a prompt or runs anything', async (_label, limit) => {
324
+ const result = await runDecision({ PromptID: ROUTING_PROMPT.ID, Payload: scopeLimitedPayload(limit) });
325
+
326
+ expect(result).toMatchObject({ success: false, errorMessage: 'RunDecision is not permitted for scope-limited sessions' });
327
+ expect(engineLoad).not.toHaveBeenCalled();
328
+ expect(executeDecision).not.toHaveBeenCalled();
329
+ });
330
+
331
+ it('runs for a session whose scope object carries no confinement', async () => {
332
+ const result = await runDecision({ Payload: scopeLimitedPayload({ IsMagicLinkAnonymous: false, MagicLinkScope: {} }) });
333
+
334
+ expect(result.success).toBe(true);
335
+ });
336
+ });
337
+
338
+ // ─── Input validation ────────────────────────────────────────────────────────
339
+
340
+ describe('RunDecision: input validation', () => {
341
+ it('rejects questions that are not valid JSON', async () => {
342
+ const result = await runDecision({ Questions: '{ route: ' });
343
+
344
+ expect(result.success).toBe(false);
345
+ expect(result.errorMessage).toMatch(/^Questions JSON parsing failed: /);
346
+ expect(executeDecision).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it('rejects a question of an unknown Kind, with the Run Decision action\'s message', async () => {
350
+ const questions = JSON.stringify({ urgent: { Kind: 'YesNo', Instructions: 'Is this urgent?' } });
351
+
352
+ const result = await runDecision({ Questions: questions });
353
+
354
+ expect(result).toMatchObject({
355
+ success: false,
356
+ errorMessage: "Question 'urgent' must have a known Kind ('Likelihood', 'Choice', or 'Score')",
357
+ });
358
+ expect(executeDecision).not.toHaveBeenCalled();
359
+ });
360
+
361
+ it('rejects a Choice question without an Options array', async () => {
362
+ const questions = JSON.stringify({ route: { Kind: 'Choice', Instructions: 'Which agent?' } });
363
+
364
+ const result = await runDecision({ Questions: questions });
365
+
366
+ expect(result).toMatchObject({ success: false, errorMessage: "Question 'route' of Kind 'Choice' must have an Options array" });
367
+ });
368
+
369
+ it('rejects an empty question map', async () => {
370
+ const result = await runDecision({ Questions: '{}' });
371
+
372
+ expect(result).toMatchObject({ success: false, errorMessage: 'Questions object must have at least one question' });
373
+ });
374
+
375
+ it('rejects an empty state and an empty state object', async () => {
376
+ const blank = await runDecision({ State: ' ' });
377
+ const emptyObject = await runDecision({ State: '{}' });
378
+
379
+ expect(blank).toMatchObject({ success: false, errorMessage: 'State is required and cannot be empty' });
380
+ expect(emptyObject).toMatchObject({ success: false, errorMessage: 'State object cannot be empty' });
381
+ expect(executeDecision).not.toHaveBeenCalled();
382
+ });
383
+
384
+ it('passes the JSON text of an object as an object', async () => {
385
+ await runDecision({ State: '{"message": "Show me last quarter\'s renewals", "channel": "chat"}' });
386
+
387
+ expect(sentParams().State).toEqual({ message: "Show me last quarter's renewals", channel: 'chat' });
388
+ });
389
+
390
+ it('passes any other text as text, including JSON that is not an object', async () => {
391
+ await runDecision({ State: ' Please cancel my order. ' });
392
+ const text = sentParams().State;
393
+ executeDecision.mockClear();
394
+ await runDecision({ State: '["not", "an", "object"]' });
395
+
396
+ expect(text).toBe('Please cancel my order.');
397
+ expect(sentParams().State).toBe('["not", "an", "object"]');
398
+ });
399
+
400
+ it('passes the validated questions, the timeout and the caller to the runner', async () => {
401
+ await runDecision({ TimeoutMS: 250 });
402
+
403
+ const params = sentParams();
404
+ expect(params.Questions).toEqual(QUESTIONS);
405
+ expect(params.timeoutMS).toBe(250);
406
+ expect(params.contextUser).toBe(USER);
407
+ });
408
+ });
409
+
410
+ // ─── Input bounds ────────────────────────────────────────────────────────────
411
+
412
+ describe('RunDecision: input bounds', () => {
413
+ /** A map of `count` Likelihood questions. */
414
+ const likelihoodQuestions = (count: number): Record<string, DecisionQuestion> =>
415
+ Object.fromEntries(
416
+ Array.from({ length: count }, (_, i): [string, DecisionQuestion] => [`q${i}`, { Kind: 'Likelihood', Instructions: `Is statement ${i} true?` }])
417
+ );
418
+
419
+ /** One Choice question with `count` options. */
420
+ const choiceWithOptions = (count: number): Record<string, DecisionQuestion> => ({
421
+ route: {
422
+ Kind: 'Choice',
423
+ Instructions: 'Which queue should take this?',
424
+ Options: Array.from({ length: count }, (_, i) => ({ Value: `queue-${i}`, Description: `Queue ${i}` })),
425
+ },
426
+ });
427
+
428
+ /** One Score question with `count` levels. */
429
+ const scoreWithLevels = (count: number): Record<string, DecisionQuestion> => ({
430
+ urgency: { Kind: 'Score', Instructions: 'How urgent is this?', Levels: Array.from({ length: count }, (_, i) => `Level ${i}`) },
431
+ });
432
+
433
+ it('runs a state at the character limit and refuses one character more', async () => {
434
+ const max = RunDecisionResolver.MAX_STATE_CHARACTERS;
435
+
436
+ const atLimit = await runDecision({ State: 'x'.repeat(max) });
437
+ const overLimit = await runDecision({ State: 'x'.repeat(max + 1) });
438
+
439
+ expect(atLimit.success).toBe(true);
440
+ expect(overLimit).toMatchObject({ success: false, errorMessage: `State has ${max + 1} characters, over the limit of ${max}` });
441
+ expect(executeDecision).toHaveBeenCalledTimes(1);
442
+ });
443
+
444
+ it('refuses questions JSON over its character limit before parsing it', async () => {
445
+ const max = RunDecisionResolver.MAX_QUESTIONS_CHARACTERS;
446
+ const unparseable = '{'.repeat(max + 1);
447
+ const oneLongQuestion = JSON.stringify({ q: { Kind: 'Likelihood', Instructions: 'x'.repeat(max) } });
448
+
449
+ const refusedUnparsed = await runDecision({ Questions: unparseable });
450
+ const refusedLong = await runDecision({ Questions: oneLongQuestion });
451
+
452
+ // The length message, not the parser's: the text was never parsed.
453
+ expect(refusedUnparsed).toMatchObject({ success: false, errorMessage: `Questions JSON has ${max + 1} characters, over the limit of ${max}` });
454
+ expect(refusedLong).toMatchObject({ success: false, errorMessage: `Questions JSON has ${oneLongQuestion.length} characters, over the limit of ${max}` });
455
+ expect(executeDecision).not.toHaveBeenCalled();
456
+ });
457
+
458
+ it('runs the most questions a request may carry and refuses one more', async () => {
459
+ const max = RunDecisionResolver.MAX_QUESTIONS;
460
+
461
+ const atLimit = await runDecision({ Questions: JSON.stringify(likelihoodQuestions(max)) });
462
+ const overLimit = await runDecision({ Questions: JSON.stringify(likelihoodQuestions(max + 1)) });
463
+
464
+ expect(atLimit.success).toBe(true);
465
+ expect(overLimit).toMatchObject({ success: false, errorMessage: `${max + 1} questions exceed the limit of ${max} per request` });
466
+ expect(executeDecision).toHaveBeenCalledTimes(1);
467
+ });
468
+
469
+ it('runs a Choice at the option limit and refuses one more option', async () => {
470
+ const max = RunDecisionResolver.MAX_OPTIONS_PER_QUESTION;
471
+
472
+ const atLimit = await runDecision({ Questions: JSON.stringify(choiceWithOptions(max)) });
473
+ const overLimit = await runDecision({ Questions: JSON.stringify(choiceWithOptions(max + 1)) });
474
+
475
+ expect(atLimit.success).toBe(true);
476
+ expect(overLimit).toMatchObject({ success: false, errorMessage: `Choice 'route' has ${max + 1} options, over the limit of ${max}` });
477
+ expect(executeDecision).toHaveBeenCalledTimes(1);
478
+ });
479
+
480
+ it('refuses a Score with more levels than a question may list', async () => {
481
+ const max = RunDecisionResolver.MAX_OPTIONS_PER_QUESTION;
482
+
483
+ const result = await runDecision({ Questions: JSON.stringify(scoreWithLevels(max + 1)) });
484
+
485
+ expect(result).toMatchObject({ success: false, errorMessage: `Score 'urgency' has ${max + 1} levels, over the limit of ${max}` });
486
+ expect(executeDecision).not.toHaveBeenCalled();
487
+ });
488
+
489
+ it.each([
490
+ ['none', undefined, RunDecisionResolver.DEFAULT_TIMEOUT_MS],
491
+ ['zero', 0, RunDecisionResolver.DEFAULT_TIMEOUT_MS],
492
+ ['a negative value', -5, RunDecisionResolver.DEFAULT_TIMEOUT_MS],
493
+ ['a value within the maximum', 250, 250],
494
+ ['the maximum', RunDecisionResolver.MAX_TIMEOUT_MS, RunDecisionResolver.MAX_TIMEOUT_MS],
495
+ ['a value over the maximum', RunDecisionResolver.MAX_TIMEOUT_MS + 1, RunDecisionResolver.MAX_TIMEOUT_MS],
496
+ ['a far larger value', 3_600_000, RunDecisionResolver.MAX_TIMEOUT_MS],
497
+ ])('bounds every model call when the caller sends %s as the timeout', async (_label, requested, expected) => {
498
+ await runDecision({ TimeoutMS: requested });
499
+
500
+ expect(sentParams().timeoutMS).toBe(expected);
501
+ });
502
+ });
503
+
504
+ // ─── Result mapping ──────────────────────────────────────────────────────────
505
+
506
+ describe('RunDecision: result mapping', () => {
507
+ it('maps a successful run: the answers as JSON, the prompt run, the model and the elapsed time', async () => {
508
+ const result = await runDecision();
509
+
510
+ expect(result.success).toBe(true);
511
+ expect(result.errorMessage).toBeUndefined();
512
+ expect(JSON.parse(result.answersJSON ?? '')).toEqual(ANSWERS);
513
+ expect(result.promptRunId).toBe('run-1');
514
+ expect(result.modelName).toBe('Jev');
515
+ expect(result.resolvedModel).toBe('typesafe/jev-1.13-20260917');
516
+ expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
517
+ });
518
+
519
+ it('leaves the resolved model out when the driver reports none', async () => {
520
+ executeDecision.mockResolvedValue({ ...successResult(), DecisionResult: undefined });
521
+
522
+ const result = await runDecision();
523
+
524
+ expect(result.success).toBe(true);
525
+ expect(result.modelName).toBe('Jev');
526
+ expect(result.resolvedModel).toBeUndefined();
527
+ });
528
+
529
+ it('maps a failed run to success: false, keeping the prompt run and the model but sending no answers', async () => {
530
+ executeDecision.mockResolvedValue(failedResult());
531
+
532
+ const result = await runDecision();
533
+
534
+ expect(result).toMatchObject({
535
+ success: false,
536
+ errorMessage: 'No Decision model has credentials available',
537
+ promptRunId: 'run-1',
538
+ modelName: 'Jev',
539
+ });
540
+ expect(result.answersJSON).toBeUndefined();
541
+ });
542
+
543
+ it('gives a failed run with no message a generic one', async () => {
544
+ executeDecision.mockResolvedValue({ ...failedResult(), errorMessage: undefined });
545
+
546
+ const result = await runDecision();
547
+
548
+ expect(result).toMatchObject({ success: false, errorMessage: 'Decision execution failed' });
549
+ });
550
+
551
+ it('returns an unexpected error as success: false instead of throwing', async () => {
552
+ executeDecision.mockRejectedValue(new Error('connection reset'));
553
+
554
+ const result = await runDecision();
555
+
556
+ expect(result).toMatchObject({ success: false, errorMessage: 'connection reset' });
557
+ expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
558
+ });
559
+
560
+ it('fails when the caller cannot be resolved to a user', async () => {
561
+ const result = await runDecision({ Payload: { email: 'nobody@example.com', userRecord: undefined, sessionId: 's-2' } });
562
+
563
+ expect(result).toMatchObject({ success: false, errorMessage: 'Unable to determine current user' });
564
+ expect(executeDecision).not.toHaveBeenCalled();
565
+ });
566
+ });
567
+
568
+ // ─── Authorization: the same gate as RunAIPrompt ────────────────────────────
569
+
570
+ describe('RunDecision: authorization mirrors RunAIPrompt', () => {
571
+ it('rejects an API key without the prompt:execute scope, with the same checks and message as RunAIPrompt', async () => {
572
+ mockAuthorize.mockResolvedValue({ Allowed: false, Reason: 'No rule grants this scope' });
573
+
574
+ const decision = await runDecision({ PromptID: DEFAULT_PROMPT.ID, Payload: apiKeyPayload() });
575
+ const decisionChecks = [...mockAuthorize.mock.calls];
576
+ mockAuthorize.mockClear();
577
+ const promptRun = new RunAIPromptResolver().RunAIPrompt(DEFAULT_PROMPT.ID, context(apiKeyPayload()));
578
+
579
+ // The same denial. RunAIPrompt throws it; RunDecision returns it, because it never throws.
580
+ expect(decision.success).toBe(false);
581
+ expect(decision.errorMessage).toContain("Access denied. This API key requires the 'prompt:execute' scope for resource 'prompt-default'");
582
+ await expect(promptRun).rejects.toThrow(decision.errorMessage);
583
+ // The same engine calls, argument for argument: the full_access probe, then prompt:execute on the prompt.
584
+ expect(decisionChecks).toEqual(mockAuthorize.mock.calls);
585
+ expect(decisionChecks.map(call => call[2])).toEqual(['full_access', 'prompt:execute']);
586
+ // And nothing ran.
587
+ expect(executeDecision).not.toHaveBeenCalled();
588
+ });
589
+
590
+ it('runs for an API key that has the scope, checking the prompt the caller named', async () => {
591
+ mockAuthorize.mockImplementation(async (_hash, _app, scope) =>
592
+ ({ Allowed: scope === 'prompt:execute', Reason: scope === 'prompt:execute' ? 'Allowed' : 'No full access' }));
593
+
594
+ const result = await runDecision({ PromptID: ROUTING_PROMPT.ID, Payload: apiKeyPayload() });
595
+
596
+ expect(result.success).toBe(true);
597
+ expect(scopeCalls()).toHaveLength(1);
598
+ expect(scopeCalls()[0].slice(0, 5)).toEqual(['hash-1', 'MJAPI', 'prompt:execute', 'prompt-routing', SYSTEM_USER]);
599
+ });
600
+
601
+ it('checks the ID of the prompt that runs, however the caller named it', async () => {
602
+ allowPromptsExcept();
603
+
604
+ await runDecision({ PromptName: 'route MESSAGE', Payload: apiKeyPayload() });
605
+ await runDecision({ PromptID: 'PROMPT-ROUTING', Payload: apiKeyPayload() });
606
+ await runDecision({ Payload: apiKeyPayload() });
607
+
608
+ expect(checkedResources()).toEqual(['prompt-routing', 'prompt-routing', 'prompt-default']);
609
+ expect(executeDecision).toHaveBeenCalledTimes(3);
610
+ });
611
+
612
+ it('refuses a caller denied a prompt by its ID when it names that prompt by name', async () => {
613
+ allowPromptsExcept(ROUTING_PROMPT.ID);
614
+
615
+ const byName = await runDecision({ PromptName: 'Route Message', Payload: apiKeyPayload() });
616
+ const byId = await runDecision({ PromptID: ROUTING_PROMPT.ID, Payload: apiKeyPayload() });
617
+ const another = await runDecision({ PromptName: 'Default Decision', Payload: apiKeyPayload() });
618
+
619
+ expect(byName.success).toBe(false);
620
+ expect(byName.errorMessage).toContain("Access denied. This API key requires the 'prompt:execute' scope for resource 'prompt-routing'");
621
+ expect(byId).toMatchObject({ success: false, errorMessage: byName.errorMessage });
622
+ // The rule is on the one prompt: another still runs, and it is the only one that did.
623
+ expect(another.success).toBe(true);
624
+ expect(executeDecision).toHaveBeenCalledTimes(1);
625
+ expect(sentParams().prompt).toBe(DEFAULT_PROMPT);
626
+ });
627
+
628
+ it('checks a prompt that is not found against the value the caller sent, and says it is missing only once allowed', async () => {
629
+ mockAuthorize.mockResolvedValue({ Allowed: false, Reason: 'Denied' });
630
+
631
+ const deniedByName = await runDecision({ PromptName: 'No Such Prompt', Payload: apiKeyPayload() });
632
+ const deniedById = await runDecision({ PromptID: 'prompt-missing', Payload: apiKeyPayload() });
633
+
634
+ expect(checkedResources()).toEqual(['No Such Prompt', 'prompt-missing']);
635
+ for (const denied of [deniedByName, deniedById]) {
636
+ expect(denied.success).toBe(false);
637
+ expect(denied.errorMessage).toContain('Access denied');
638
+ expect(denied.errorMessage).not.toContain('not found');
639
+ }
640
+
641
+ mockAuthorize.mockClear();
642
+ allowPromptsExcept();
643
+ const allowed = await runDecision({ PromptName: 'No Such Prompt', Payload: apiKeyPayload() });
644
+
645
+ expect(checkedResources()).toEqual(['No Such Prompt']);
646
+ expect(allowed).toMatchObject({ success: false, errorMessage: "AI Prompt 'No Such Prompt' was not found or is not an active Decision prompt" });
647
+ expect(executeDecision).not.toHaveBeenCalled();
648
+ });
649
+
650
+ it('checks a prompt that appears only once the engine loads by its ID too, so a cold cache cannot be passed by name', async () => {
651
+ let loaded = false;
652
+ vi.spyOn(AIEngine.prototype, 'Config').mockImplementation(async () => {
653
+ loaded = true;
654
+ });
655
+ vi.spyOn(AIEngine.prototype, 'Prompts', 'get').mockImplementation(() => (loaded ? [DEFAULT_PROMPT, ROUTING_PROMPT] : []));
656
+ allowPromptsExcept(ROUTING_PROMPT.ID);
657
+
658
+ const denied = await runDecision({ PromptName: 'Route Message', Payload: apiKeyPayload() });
659
+
660
+ expect(checkedResources()).toEqual(['Route Message', 'prompt-routing']);
661
+ expect(denied.success).toBe(false);
662
+ expect(denied.errorMessage).toContain("scope for resource 'prompt-routing'");
663
+ expect(executeDecision).not.toHaveBeenCalled();
664
+ });
665
+
666
+ it('treats a prompt cache that cannot be read as not found until the caller is authorized', async () => {
667
+ vi.spyOn(AIEngine.prototype, 'Prompts', 'get').mockImplementation(() => {
668
+ throw new Error('AIEngine cannot read MJ: AI Prompts');
669
+ });
670
+ mockAuthorize.mockResolvedValue({ Allowed: false, Reason: 'Denied' });
671
+
672
+ const denied = await runDecision({ PromptName: 'Route Message', Payload: apiKeyPayload() });
673
+ mockAuthorize.mockClear();
674
+ allowPromptsExcept();
675
+ const allowed = await runDecision({ PromptName: 'Route Message', Payload: apiKeyPayload() });
676
+
677
+ // Denied, the caller hears only the denial; allowed, it hears why the prompt cannot be read.
678
+ expect(denied.errorMessage).toContain('Access denied');
679
+ expect(denied.errorMessage).not.toContain('cannot read');
680
+ expect(allowed).toMatchObject({ success: false, errorMessage: 'AIEngine cannot read MJ: AI Prompts' });
681
+ expect(executeDecision).not.toHaveBeenCalled();
682
+ });
683
+
684
+ it('checks before anything else, so a denied caller learns nothing about the prompt or the input', async () => {
685
+ mockAuthorize.mockResolvedValue({ Allowed: false, Reason: 'Denied' });
686
+
687
+ const result = await runDecision({ PromptID: 'prompt-missing', Questions: 'not json', Payload: apiKeyPayload() });
688
+
689
+ expect(result.success).toBe(false);
690
+ expect(result.errorMessage).toContain('Access denied');
691
+ });
692
+
693
+ it('skips the scope check for a session without an API key, as RunAIPrompt does', async () => {
694
+ const result = await runDecision({ Payload: sessionPayload() });
695
+
696
+ expect(result.success).toBe(true);
697
+ expect(mockAuthorize).not.toHaveBeenCalled();
698
+ });
699
+ });