@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +1010 -0
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +5178 -3
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.js +16 -2
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
- package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
- package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
- package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
- package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
- package/dist/generic/PushStatusResolver.d.ts +38 -0
- package/dist/generic/PushStatusResolver.d.ts.map +1 -1
- package/dist/generic/PushStatusResolver.js +84 -1
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +13 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +49 -47
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +90 -2
- package/dist/index.js.map +1 -1
- package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
- package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
- package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
- package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
- package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
- package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
- package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
- package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
- package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
- package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.js +16 -2
- package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +23 -153
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
- package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
- package/dist/resolvers/RunDecisionResolver.js +396 -0
- package/dist/resolvers/RunDecisionResolver.js.map +1 -0
- package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -10
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/package.json +94 -92
- package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
- package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
- package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
- package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
- package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
- package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
- package/src/__tests__/RunDecisionResolver.test.ts +699 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
- package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
- package/src/__tests__/conversationRunEvents.test.ts +399 -0
- package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
- package/src/__tests__/pushStatusFanOut.test.ts +206 -0
- package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
- package/src/generated/graphql-schemas/__mj.ts +3637 -4
- package/src/generic/FireAndForgetHeartbeat.ts +16 -2
- package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
- package/src/generic/PushStatusResolver.ts +97 -1
- package/src/generic/ResolverBase.ts +49 -49
- package/src/index.ts +102 -2
- package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
- package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
- package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
- package/src/resolvers/MergeRecordsResolver.ts +14 -2
- package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
- package/src/resolvers/RunAIAgentResolver.ts +28 -174
- package/src/resolvers/RunDecisionResolver.ts +435 -0
- package/src/resolvers/VectorizeEntityResolver.ts +16 -11
- package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
|
@@ -0,0 +1,300 @@
|
|
|
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 } 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
|
+
) => Promise<{ Allowed: boolean; Reason: string }>;
|
|
14
|
+
|
|
15
|
+
const { mockAuthorize, mockLogError } = vi.hoisted(() => ({
|
|
16
|
+
mockAuthorize: vi.fn<AuthorizeFn>(),
|
|
17
|
+
mockLogError: vi.fn<(message: string) => void>(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// As in RunDecisionResolver.test.ts: vitest's esbuild transform does not apply
|
|
21
|
+
// `emitDecoratorMetadata`, so the decorators are no-ops and the resolver runs as a plain class.
|
|
22
|
+
vi.mock('type-graphql', () => {
|
|
23
|
+
const noopDecorator = () => () => undefined;
|
|
24
|
+
return {
|
|
25
|
+
Resolver: noopDecorator, Mutation: noopDecorator, Query: noopDecorator, Subscription: noopDecorator,
|
|
26
|
+
ObjectType: noopDecorator, InputType: noopDecorator, Field: noopDecorator, Arg: noopDecorator,
|
|
27
|
+
Args: noopDecorator, Ctx: noopDecorator, PubSub: noopDecorator, Root: noopDecorator,
|
|
28
|
+
Directive: noopDecorator, Authorized: noopDecorator, UseMiddleware: noopDecorator,
|
|
29
|
+
createUnionType: () => class {},
|
|
30
|
+
AuthorizationError: class AuthorizationError extends Error {
|
|
31
|
+
constructor(message?: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'AuthorizationError';
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
Float: class {}, Int: class {}, ID: class {},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Only logging is replaced, so a test can tell a logged fault from a quiet answer.
|
|
41
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
42
|
+
const actual = await importOriginal<Record<string, unknown>>();
|
|
43
|
+
return { ...actual, LogError: mockLogError };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Only the API-key engine is replaced; ResolverBase's scope check itself is the production code.
|
|
47
|
+
vi.mock('@memberjunction/api-keys', async (importOriginal) => {
|
|
48
|
+
const actual = await importOriginal<Record<string, unknown>>();
|
|
49
|
+
return { ...actual, GetAPIKeyEngine: () => ({ Authorize: mockAuthorize }) };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The resolver's tests replace the detector through CreateEntryChecker, so the detector package
|
|
53
|
+
// is never exercised here; it has its own specs.
|
|
54
|
+
vi.mock('@memberjunction/ai-vector-dupe', () => ({ DuplicateRecordDetector: class {} }));
|
|
55
|
+
|
|
56
|
+
import { EntityInfo, Metadata, UserInfo } from '@memberjunction/core';
|
|
57
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
58
|
+
import type { DuplicateEntryCheckResult } from '@memberjunction/ai-vector-dupe';
|
|
59
|
+
import {
|
|
60
|
+
DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH,
|
|
61
|
+
DuplicateEntryCheckResolver,
|
|
62
|
+
type DuplicateEntryChecker,
|
|
63
|
+
} from '../resolvers/DuplicateEntryCheckResolver.js';
|
|
64
|
+
import type { UserPayload } from '../types.js';
|
|
65
|
+
|
|
66
|
+
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const USER = new UserInfo(null, {
|
|
69
|
+
ID: 'user-1',
|
|
70
|
+
Name: 'Entry Person',
|
|
71
|
+
Email: 'person@example.com',
|
|
72
|
+
UserRoles: [{ UserID: 'user-1', RoleID: 'role-ui' }],
|
|
73
|
+
});
|
|
74
|
+
const SYSTEM_USER = new UserInfo(null, { ID: 'user-system', Name: 'System', Email: 'system@example.com' });
|
|
75
|
+
|
|
76
|
+
/** An entity the user's role may read. */
|
|
77
|
+
const ACCOUNTS = new EntityInfo({
|
|
78
|
+
ID: 'entity-accounts',
|
|
79
|
+
Name: 'Accounts',
|
|
80
|
+
Status: 'Active',
|
|
81
|
+
Fields: [
|
|
82
|
+
{ Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true },
|
|
83
|
+
{ Name: 'Name', Type: 'nvarchar', IsNameField: true, AllowUpdateAPI: true },
|
|
84
|
+
{ Name: 'City', Type: 'nvarchar', AllowUpdateAPI: true },
|
|
85
|
+
{ Name: 'Owner', Type: 'nvarchar', AllowUpdateAPI: false },
|
|
86
|
+
{ Name: '__mj_CreatedAt', Type: 'datetimeoffset', AllowUpdateAPI: false },
|
|
87
|
+
],
|
|
88
|
+
EntityPermissions: [{ RoleID: 'role-ui', CanRead: true }],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/** An entity only another role may read. */
|
|
92
|
+
const PAYROLL = new EntityInfo({
|
|
93
|
+
ID: 'entity-payroll',
|
|
94
|
+
Name: 'Payroll',
|
|
95
|
+
Status: 'Active',
|
|
96
|
+
Fields: [{ Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true }],
|
|
97
|
+
EntityPermissions: [{ RoleID: 'role-finance', CanRead: true }],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const CHECKED: DuplicateEntryCheckResult = {
|
|
101
|
+
Status: 'Checked',
|
|
102
|
+
Candidates: [{ RecordID: 'acct-7', DisplayName: 'Acme Corp', VectorScore: 0.93, Probability: 0.81 }],
|
|
103
|
+
ElapsedMs: 212,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** A session authenticated by JWT: no API key, so no scope check. */
|
|
107
|
+
const sessionPayload = (): UserPayload => ({ email: USER.Email, userRecord: USER, sessionId: 'session-1' });
|
|
108
|
+
/** A session authenticated by an MJ API key: scope-checked. */
|
|
109
|
+
const apiKeyPayload = (): UserPayload => ({ ...sessionPayload(), apiKeyId: 'key-1', apiKeyHash: 'hash-1' });
|
|
110
|
+
|
|
111
|
+
/** The resolver with its detector replaced by a double. */
|
|
112
|
+
class TestResolver extends DuplicateEntryCheckResolver {
|
|
113
|
+
public readonly Checker = { CheckRecordValues: vi.fn<DuplicateEntryChecker['CheckRecordValues']>() };
|
|
114
|
+
|
|
115
|
+
protected override CreateEntryChecker(): DuplicateEntryChecker {
|
|
116
|
+
return this.Checker;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let resolver: TestResolver;
|
|
121
|
+
|
|
122
|
+
const check = (entityName: string, valuesJSON: string, payload: UserPayload = sessionPayload()) =>
|
|
123
|
+
resolver.RunEntryCheck(entityName, valuesJSON, payload, null);
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
vi.restoreAllMocks();
|
|
127
|
+
mockAuthorize.mockReset();
|
|
128
|
+
mockLogError.mockReset();
|
|
129
|
+
vi.spyOn(Metadata.prototype, 'EntityByName').mockImplementation((name: string) =>
|
|
130
|
+
[ACCOUNTS, PAYROLL].find(e => e.Name.toLowerCase() === name.trim().toLowerCase())
|
|
131
|
+
);
|
|
132
|
+
vi.spyOn(UserCache.prototype, 'GetSystemUser').mockReturnValue(SYSTEM_USER);
|
|
133
|
+
resolver = new TestResolver();
|
|
134
|
+
resolver.Checker.CheckRecordValues.mockResolvedValue(CHECKED);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ─── Authorization ───────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/** The scope checks made, other than the full_access fast path, as [scope, resource]. */
|
|
140
|
+
const scopeChecks = () => mockAuthorize.mock.calls.filter(call => call[2] !== 'full_access').map(call => [call[2], call[3]]);
|
|
141
|
+
|
|
142
|
+
describe('CheckDuplicateEntry: authorization', () => {
|
|
143
|
+
it('answers NotAuthorized, without logging, for a user who cannot read the entity', async () => {
|
|
144
|
+
const result = await check('Payroll', '{"ID":"x"}');
|
|
145
|
+
|
|
146
|
+
expect(result.Status).toBe('NotAuthorized');
|
|
147
|
+
expect(result.ErrorMessage).toContain('does not have read permissions on Payroll');
|
|
148
|
+
expect(result.Candidates).toEqual([]);
|
|
149
|
+
expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
|
|
150
|
+
expect(mockLogError).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('fails an unknown entity without running the check', async () => {
|
|
154
|
+
const result = await check('No Such Entity', '{}');
|
|
155
|
+
|
|
156
|
+
expect(result).toMatchObject({ Status: 'Failed', ErrorMessage: 'Entity not found in metadata' });
|
|
157
|
+
expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('applies the view:run API-key scope for the entity, and a denial is a status', async () => {
|
|
161
|
+
mockAuthorize.mockResolvedValue({ Allowed: false, Reason: 'scope not granted' });
|
|
162
|
+
|
|
163
|
+
const result = await check('Accounts', '{"Name":"Acme"}', apiKeyPayload());
|
|
164
|
+
|
|
165
|
+
expect(scopeChecks()).toEqual([['view:run', 'Accounts']]);
|
|
166
|
+
expect(result.Status).toBe('NotAuthorized');
|
|
167
|
+
expect(result.ErrorMessage).toContain("requires the 'view:run' scope");
|
|
168
|
+
expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
|
|
169
|
+
expect(mockLogError).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('also requires prompt:execute, so a read-only key cannot spend on model calls', async () => {
|
|
173
|
+
mockAuthorize.mockImplementation(async (_hash, _app, scopePath) =>
|
|
174
|
+
scopePath === 'view:run' ? { Allowed: true, Reason: '' } : { Allowed: false, Reason: 'scope not granted' }
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const result = await check('Accounts', '{"Name":"Acme"}', apiKeyPayload());
|
|
178
|
+
|
|
179
|
+
expect(scopeChecks()).toEqual([['view:run', 'Accounts'], ['prompt:execute', '*']]);
|
|
180
|
+
expect(result.Status).toBe('NotAuthorized');
|
|
181
|
+
expect(result.ErrorMessage).toContain("requires the 'prompt:execute' scope");
|
|
182
|
+
expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('runs the check for a key holding both scopes', async () => {
|
|
186
|
+
mockAuthorize.mockImplementation(async (_hash, _app, scopePath) =>
|
|
187
|
+
({ Allowed: scopePath !== 'full_access', Reason: '' })
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const result = await check('Accounts', '{"Name":"Acme"}', apiKeyPayload());
|
|
191
|
+
|
|
192
|
+
expect(scopeChecks()).toEqual([['view:run', 'Accounts'], ['prompt:execute', '*']]);
|
|
193
|
+
expect(result.Status).toBe('Checked');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ─── The values ──────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
describe('CheckDuplicateEntry: the values', () => {
|
|
200
|
+
it('returns an error status, not a throw, for values that are not JSON', async () => {
|
|
201
|
+
const result = await check('Accounts', '{ Name: ');
|
|
202
|
+
|
|
203
|
+
expect(result.Status).toBe('Failed');
|
|
204
|
+
expect(result.ErrorMessage).toMatch(/^valuesJSON is not valid JSON: /);
|
|
205
|
+
expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('returns an error status for JSON that is not an object', async () => {
|
|
209
|
+
const result = await check('Accounts', '["Acme"]');
|
|
210
|
+
|
|
211
|
+
expect(result).toMatchObject({ Status: 'Failed', ErrorMessage: 'valuesJSON must be the JSON of an object of field values' });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('ignores the fields the entity does not have, and keys the rest by the real field name', async () => {
|
|
215
|
+
await check('Accounts', JSON.stringify({ name: 'Acme', City: 'Boston', Revenue: 5, NotAField: 'x' }));
|
|
216
|
+
|
|
217
|
+
expect(resolver.Checker.CheckRecordValues).toHaveBeenCalledWith('Accounts', { Name: 'Acme', City: 'Boston' }, USER);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('ignores primary keys, read-only fields and MJ system fields', async () => {
|
|
221
|
+
await check('Accounts', JSON.stringify({ ID: 'acct-7', Name: 'Acme', Owner: 'someone', __mj_CreatedAt: '2026-01-01' }));
|
|
222
|
+
|
|
223
|
+
expect(resolver.Checker.CheckRecordValues).toHaveBeenCalledWith('Accounts', { Name: 'Acme' }, USER);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('accepts strings, numbers, booleans and null', async () => {
|
|
227
|
+
await check('Accounts', JSON.stringify({ Name: 'Acme', City: null, Revenue: 5, Active: true }));
|
|
228
|
+
|
|
229
|
+
expect(resolver.Checker.CheckRecordValues).toHaveBeenCalledWith('Accounts', { Name: 'Acme', City: null }, USER);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it.each([
|
|
233
|
+
['an array', { Name: ['Acme', 'Corp'] }],
|
|
234
|
+
['an object', { Name: { first: 'Acme' } }],
|
|
235
|
+
])('refuses a value that is %s', async (_kind, values) => {
|
|
236
|
+
const result = await check('Accounts', JSON.stringify(values));
|
|
237
|
+
|
|
238
|
+
expect(result).toMatchObject({ Status: 'Failed', ErrorMessage: "valuesJSON field 'Name' must be a string, number, boolean or null" });
|
|
239
|
+
expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('refuses a value of an unknown field that is not a scalar', async () => {
|
|
243
|
+
const result = await check('Accounts', JSON.stringify({ Name: 'Acme', Extra: [1, 2, 3] }));
|
|
244
|
+
|
|
245
|
+
expect(result.Status).toBe('Failed');
|
|
246
|
+
expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('accepts valuesJSON up to DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH characters and refuses one more', async () => {
|
|
250
|
+
const padding = DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH - JSON.stringify({ Name: '' }).length;
|
|
251
|
+
const atLimit = JSON.stringify({ Name: 'x'.repeat(padding) });
|
|
252
|
+
const overLimit = JSON.stringify({ Name: 'x'.repeat(padding + 1) });
|
|
253
|
+
expect(atLimit).toHaveLength(DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH);
|
|
254
|
+
|
|
255
|
+
expect((await check('Accounts', atLimit)).Status).toBe('Checked');
|
|
256
|
+
const refused = await check('Accounts', overLimit);
|
|
257
|
+
|
|
258
|
+
expect(refused).toMatchObject({
|
|
259
|
+
Status: 'Failed',
|
|
260
|
+
ErrorMessage: `valuesJSON is ${DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH + 1} characters; the limit is ${DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH}`,
|
|
261
|
+
});
|
|
262
|
+
expect(resolver.Checker.CheckRecordValues).toHaveBeenCalledTimes(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('keeps the limit to a few KB', () => {
|
|
266
|
+
expect(DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH).toBeLessThanOrEqual(32 * 1024);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ─── The result ──────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
describe('CheckDuplicateEntry: the result', () => {
|
|
273
|
+
it('returns the detector result', async () => {
|
|
274
|
+
const result = await check('Accounts', '{"Name":"Acme"}');
|
|
275
|
+
|
|
276
|
+
expect(result).toEqual({
|
|
277
|
+
Status: 'Checked',
|
|
278
|
+
ErrorMessage: undefined,
|
|
279
|
+
Candidates: [{ RecordID: 'acct-7', DisplayName: 'Acme Corp', VectorScore: 0.93, Probability: 0.81 }],
|
|
280
|
+
ElapsedMs: 212,
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('passes NotConfigured through', async () => {
|
|
285
|
+
resolver.Checker.CheckRecordValues.mockResolvedValue({ Status: 'NotConfigured', Candidates: [], ElapsedMs: 3 });
|
|
286
|
+
|
|
287
|
+
const result = await check('Accounts', '{"Name":"Acme"}');
|
|
288
|
+
|
|
289
|
+
expect(result).toMatchObject({ Status: 'NotConfigured', Candidates: [] });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('returns Failed, not a throw, when the check throws, and logs it', async () => {
|
|
293
|
+
resolver.Checker.CheckRecordValues.mockRejectedValue(new Error('vector index unreachable'));
|
|
294
|
+
|
|
295
|
+
const result = await check('Accounts', '{"Name":"Acme"}');
|
|
296
|
+
|
|
297
|
+
expect(result).toMatchObject({ Status: 'Failed', ErrorMessage: 'vector index unreachable', Candidates: [] });
|
|
298
|
+
expect(mockLogError).toHaveBeenCalledTimes(1);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// bring it in automatically — this line MUST precede any import that pulls in the resolver file.
|
|
4
4
|
import 'reflect-metadata';
|
|
5
5
|
|
|
6
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
7
|
|
|
8
8
|
// --- Mock CanRun authorization (AIAgentPermissionHelper.HasPermission) plus the
|
|
9
9
|
// provider-scoped AIEngineBase whose cached metadata serves the resolver's pairing-row
|
|
@@ -210,9 +210,23 @@ interface FakeSession {
|
|
|
210
210
|
NewRecord: () => void;
|
|
211
211
|
Save: () => Promise<boolean>;
|
|
212
212
|
Load: (id: string) => Promise<boolean>;
|
|
213
|
-
LatestResult?: { CompleteMessage?: string };
|
|
213
|
+
LatestResult?: { CompleteMessage?: string; Success?: boolean };
|
|
214
|
+
/** Only set on the fakes exercising `describeSaveFailure`'s "no failure detail recorded" branches. */
|
|
215
|
+
ResultHistory?: unknown[];
|
|
214
216
|
}
|
|
215
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A `console.error` spy shared by the save-failure-log tests (#4791) below. Assigned with
|
|
220
|
+
* `vi.spyOn(console, 'error').mockImplementation(() => undefined)` inside each test that needs it
|
|
221
|
+
* — restored here so no test leaks a mocked `console.error` into the ones after it.
|
|
222
|
+
*/
|
|
223
|
+
let errSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
224
|
+
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
errSpy?.mockRestore();
|
|
227
|
+
errSpy = undefined;
|
|
228
|
+
});
|
|
229
|
+
|
|
216
230
|
function makeSessionEntity(overrides: Partial<FakeSession> = {}): FakeSession {
|
|
217
231
|
return {
|
|
218
232
|
ID: 'session-1',
|
|
@@ -848,6 +862,28 @@ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
|
|
|
848
862
|
resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'x', '{}', makeCtx(), makePubSub()),
|
|
849
863
|
).rejects.toThrow(/no target agent configured/i);
|
|
850
864
|
});
|
|
865
|
+
|
|
866
|
+
it('logs the session id and "no failure detail recorded" when updatePendingFeedbackRunID\'s save is refused silently (#4791)', async () => {
|
|
867
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
868
|
+
const session = makeSessionEntity({
|
|
869
|
+
Config_: JSON.stringify({ targetAgentID: 'target-1' }),
|
|
870
|
+
Save: vi.fn(async () => false),
|
|
871
|
+
LatestResult: undefined,
|
|
872
|
+
ResultHistory: [],
|
|
873
|
+
});
|
|
874
|
+
currentProvider = makeProvider(() => session);
|
|
875
|
+
executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"q":1}', Success: true, PausedRunID: 'paused-1' });
|
|
876
|
+
const resolver = makeResolver();
|
|
877
|
+
|
|
878
|
+
await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
|
|
879
|
+
|
|
880
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
881
|
+
const failureLine = loggedLines.find((line) => line.includes('updatePendingFeedbackRunID'));
|
|
882
|
+
expect(failureLine).toBeDefined();
|
|
883
|
+
expect(failureLine).toContain('session-1');
|
|
884
|
+
expect(failureLine).toContain('no failure detail recorded');
|
|
885
|
+
expect(failureLine).not.toContain('unknown error');
|
|
886
|
+
});
|
|
851
887
|
});
|
|
852
888
|
|
|
853
889
|
describe('RealtimeClientSessionResolver.RelayRealtimeTranscript', () => {
|
|
@@ -1690,6 +1726,7 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
|
|
|
1690
1726
|
});
|
|
1691
1727
|
|
|
1692
1728
|
it('returns a structured failure when the artifact header save fails (no version attempted)', async () => {
|
|
1729
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
1693
1730
|
const failingArtifact = makeSessionEntity({
|
|
1694
1731
|
ID: 'artifact-fail',
|
|
1695
1732
|
Save: vi.fn(async () => false),
|
|
@@ -1706,10 +1743,14 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
|
|
|
1706
1743
|
|
|
1707
1744
|
expect(result.Success).toBe(false);
|
|
1708
1745
|
expect(result.ErrorMessage).toMatch(/artifact save failed/i);
|
|
1746
|
+
expect(result.ErrorMessage).toContain('session session-1');
|
|
1747
|
+
expect(result.ErrorMessage).toContain('db down');
|
|
1748
|
+
expect(errSpy.mock.calls.map((call) => String(call[0]))).toContain(result.ErrorMessage);
|
|
1709
1749
|
expect(version.Save).not.toHaveBeenCalled();
|
|
1710
1750
|
});
|
|
1711
1751
|
|
|
1712
1752
|
it('returns a structured failure (carrying the orphaned ArtifactID) when the version save fails', async () => {
|
|
1753
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
1713
1754
|
const failingVersion = makeSessionEntity({
|
|
1714
1755
|
ID: 'version-fail',
|
|
1715
1756
|
Save: vi.fn(async () => false),
|
|
@@ -1728,6 +1769,10 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
|
|
|
1728
1769
|
expect(result.Success).toBe(false);
|
|
1729
1770
|
expect(result.ArtifactID).toBe('artifact-1');
|
|
1730
1771
|
expect(result.ArtifactVersionID).toBeUndefined();
|
|
1772
|
+
expect(result.ErrorMessage).toMatch(/artifact version save failed/i);
|
|
1773
|
+
expect(result.ErrorMessage).toContain('session session-1');
|
|
1774
|
+
expect(result.ErrorMessage).toContain('too big');
|
|
1775
|
+
expect(errSpy.mock.calls.map((call) => String(call[0]))).toContain(result.ErrorMessage);
|
|
1731
1776
|
expect(junction.Save).not.toHaveBeenCalled();
|
|
1732
1777
|
});
|
|
1733
1778
|
|
|
@@ -2880,6 +2925,44 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
|
|
|
2880
2925
|
expect(executeRelayedToolMock.mock.calls[0][1]).toBe(ANON_USER);
|
|
2881
2926
|
});
|
|
2882
2927
|
|
|
2928
|
+
it('writes the hidden direct-action tool turn (ExecuteRealtimeSessionTool) as the CALLER, not the system user (#4791)', async () => {
|
|
2929
|
+
const detail = makeSessionEntity({ ID: 'detail-1' });
|
|
2930
|
+
currentProvider = {
|
|
2931
|
+
GetEntityObject: vi.fn(async (name: string) =>
|
|
2932
|
+
name === 'MJ: Conversation Details' ? detail : makeSessionEntity({ UserID: 'anon-1' }),
|
|
2933
|
+
),
|
|
2934
|
+
};
|
|
2935
|
+
executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"success":true}', Success: true });
|
|
2936
|
+
const resolver = makeAnonResolver();
|
|
2937
|
+
|
|
2938
|
+
await resolver.ExecuteRealtimeSessionTool(
|
|
2939
|
+
'session-1', 'call-1', 'Some Direct Action', '{}', makeCtx(), makePubSub(),
|
|
2940
|
+
);
|
|
2941
|
+
|
|
2942
|
+
// The dispatch itself still runs elevated (SYSTEM_USER)…
|
|
2943
|
+
expect(executeRelayedToolMock.mock.calls[0][1]).toBe(SYSTEM_USER);
|
|
2944
|
+
// …but the hidden Conversation Detail turn is written as the CALLER, never the system user.
|
|
2945
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
|
|
2946
|
+
expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
|
|
2947
|
+
expect(detail.Save).toHaveBeenCalled();
|
|
2948
|
+
expect(detail.UserID).toBe('anon-1');
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
it('writes the direct-action tool turn as the caller unchanged for a normal authenticated user', async () => {
|
|
2952
|
+
const detail = makeSessionEntity({ ID: 'detail-2' });
|
|
2953
|
+
currentProvider = {
|
|
2954
|
+
GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: Conversation Details' ? detail : makeSessionEntity())),
|
|
2955
|
+
};
|
|
2956
|
+
executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"success":true}', Success: true });
|
|
2957
|
+
const resolver = makeResolver();
|
|
2958
|
+
|
|
2959
|
+
await resolver.ExecuteRealtimeSessionTool(
|
|
2960
|
+
'session-1', 'call-1', 'Some Direct Action', '{}', makeCtx(), makePubSub(),
|
|
2961
|
+
);
|
|
2962
|
+
|
|
2963
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', USER);
|
|
2964
|
+
});
|
|
2965
|
+
|
|
2883
2966
|
it('accumulates relayed usage onto the prompt run under the SYSTEM user', async () => {
|
|
2884
2967
|
const session = makeSessionEntity({
|
|
2885
2968
|
UserID: 'anon-1',
|
|
@@ -2942,6 +3025,32 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
|
|
|
2942
3025
|
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
|
|
2943
3026
|
});
|
|
2944
3027
|
|
|
3028
|
+
it('writes the hidden co-agent tool turn (RelayRealtimeToolTurn) as the CALLER (#4791)', async () => {
|
|
3029
|
+
const session = makeSessionEntity({
|
|
3030
|
+
UserID: 'anon-1',
|
|
3031
|
+
Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
|
|
3032
|
+
});
|
|
3033
|
+
const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
|
|
3034
|
+
const detail = makeSessionEntity({ ID: 'detail-3' });
|
|
3035
|
+
currentProvider = {
|
|
3036
|
+
GetEntityObject: vi.fn(async (name: string) => {
|
|
3037
|
+
if (name === 'MJ: AI Prompt Runs') return promptRun;
|
|
3038
|
+
if (name === 'MJ: Conversation Details') return detail;
|
|
3039
|
+
return session;
|
|
3040
|
+
}),
|
|
3041
|
+
};
|
|
3042
|
+
const resolver = makeAnonResolver();
|
|
3043
|
+
|
|
3044
|
+
const ok = await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{"url":"x"}');
|
|
3045
|
+
|
|
3046
|
+
expect(ok).toBe(true);
|
|
3047
|
+
// The co-agent run mirror stays SYSTEM…
|
|
3048
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
|
|
3049
|
+
// …but the hidden Conversation Detail tool-turn is written as the CALLER, never SYSTEM.
|
|
3050
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
|
|
3051
|
+
expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
|
|
3052
|
+
});
|
|
3053
|
+
|
|
2945
3054
|
it('prepares the session with observability under the SYSTEM user while UserID stays the visitor', async () => {
|
|
2946
3055
|
hasPermissionMock.mockResolvedValue(true);
|
|
2947
3056
|
currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'anon-1' }));
|
|
@@ -3003,6 +3112,10 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
|
|
|
3003
3112
|
// The junction write (an entity the anon role does NOT hold) runs as the SYSTEM user.
|
|
3004
3113
|
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Detail Artifacts', SYSTEM_USER);
|
|
3005
3114
|
expect(junctions).toHaveLength(1);
|
|
3115
|
+
// The hidden anchor `Conversation Detail` is written as the CALLER (#4791) — like every
|
|
3116
|
+
// other `MJ: Conversation Details` write, the System user is refused as not the owner.
|
|
3117
|
+
expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
|
|
3118
|
+
expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
|
|
3006
3119
|
// The hidden anchor is attributed to the SESSION owner, not the elevated principal.
|
|
3007
3120
|
expect(anchors).toHaveLength(1);
|
|
3008
3121
|
expect(anchors[0].UserID).toBe('anon-1');
|
|
@@ -3102,6 +3215,107 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
|
|
|
3102
3215
|
expect((storeRecordingMock.mock.calls[0][0] as { ContextUser: unknown }).ContextUser).toBe(USER);
|
|
3103
3216
|
expect(getSystemUserMock).not.toHaveBeenCalled();
|
|
3104
3217
|
});
|
|
3218
|
+
|
|
3219
|
+
/** Provider routing a hidden-tool-turn RelayRealtimeToolTurn call: session, prompt run, detail. */
|
|
3220
|
+
function makeToolTurnProvider(detail: FakeSession): unknown {
|
|
3221
|
+
const session = makeSessionEntity({
|
|
3222
|
+
UserID: 'anon-1',
|
|
3223
|
+
Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
|
|
3224
|
+
});
|
|
3225
|
+
const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
|
|
3226
|
+
return {
|
|
3227
|
+
GetEntityObject: vi.fn(async (name: string) => {
|
|
3228
|
+
if (name === 'MJ: AI Prompt Runs') return promptRun;
|
|
3229
|
+
if (name === 'MJ: Conversation Details') return detail;
|
|
3230
|
+
return session;
|
|
3231
|
+
}),
|
|
3232
|
+
};
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
it('logs the session id, write user id and "no failure detail recorded" when a hidden tool turn save is refused without a LatestResult (#4791)', async () => {
|
|
3236
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
3237
|
+
const detail = makeSessionEntity({
|
|
3238
|
+
ID: 'detail-1',
|
|
3239
|
+
Save: vi.fn(async () => false),
|
|
3240
|
+
LatestResult: undefined,
|
|
3241
|
+
ResultHistory: [],
|
|
3242
|
+
});
|
|
3243
|
+
currentProvider = makeToolTurnProvider(detail);
|
|
3244
|
+
const resolver = makeAnonResolver();
|
|
3245
|
+
|
|
3246
|
+
await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
|
|
3247
|
+
|
|
3248
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
3249
|
+
const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
|
|
3250
|
+
expect(failureLine).toBeDefined();
|
|
3251
|
+
// Exact substrings: 'anon-1' alone is the caller, session.UserID AND detail.UserID at once,
|
|
3252
|
+
// so only the labelled form proves the WRITE user is what got logged.
|
|
3253
|
+
expect(failureLine).toContain('session session-1');
|
|
3254
|
+
expect(failureLine).toContain('write user anon-1');
|
|
3255
|
+
expect(failureLine).toContain('no failure detail recorded (LatestResult null; ResultHistory length 0)');
|
|
3256
|
+
expect(failureLine).toContain('the write was refused before reaching the provider');
|
|
3257
|
+
expect(failureLine).not.toContain('unknown error');
|
|
3258
|
+
});
|
|
3259
|
+
|
|
3260
|
+
it('does not claim WHERE the write was refused when a result was registered with an empty message (#4791)', async () => {
|
|
3261
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
3262
|
+
const detail = makeSessionEntity({
|
|
3263
|
+
ID: 'detail-1',
|
|
3264
|
+
Save: vi.fn(async () => false),
|
|
3265
|
+
LatestResult: { Success: false, CompleteMessage: ' ' },
|
|
3266
|
+
ResultHistory: [{}],
|
|
3267
|
+
});
|
|
3268
|
+
currentProvider = makeToolTurnProvider(detail);
|
|
3269
|
+
const resolver = makeAnonResolver();
|
|
3270
|
+
|
|
3271
|
+
await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
|
|
3272
|
+
|
|
3273
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
3274
|
+
const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
|
|
3275
|
+
expect(failureLine).toBeDefined();
|
|
3276
|
+
expect(failureLine).toContain('no failure detail recorded (LatestResult empty; ResultHistory length 1)');
|
|
3277
|
+
expect(failureLine).not.toContain('refused before reaching the provider');
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
it('never logs a PRIOR SUCCESS entry\'s text as the failure reason when the refusal registered nothing (#4791)', async () => {
|
|
3281
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
3282
|
+
const detail = makeSessionEntity({
|
|
3283
|
+
ID: 'detail-1',
|
|
3284
|
+
Save: vi.fn(async () => false),
|
|
3285
|
+
LatestResult: { Success: true, CompleteMessage: 'previous save ok' },
|
|
3286
|
+
ResultHistory: [{}, {}],
|
|
3287
|
+
});
|
|
3288
|
+
currentProvider = makeToolTurnProvider(detail);
|
|
3289
|
+
const resolver = makeAnonResolver();
|
|
3290
|
+
|
|
3291
|
+
await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
|
|
3292
|
+
|
|
3293
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
3294
|
+
const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
|
|
3295
|
+
expect(failureLine).toBeDefined();
|
|
3296
|
+
expect(failureLine).toContain(
|
|
3297
|
+
'no failure detail recorded (latest result entry is a prior success; ResultHistory length 2)',
|
|
3298
|
+
);
|
|
3299
|
+
expect(failureLine).not.toContain('previous save ok');
|
|
3300
|
+
});
|
|
3301
|
+
|
|
3302
|
+
it('logs the real CompleteMessage when present (#4791)', async () => {
|
|
3303
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
3304
|
+
const detail = makeSessionEntity({
|
|
3305
|
+
ID: 'detail-1',
|
|
3306
|
+
Save: vi.fn(async () => false),
|
|
3307
|
+
LatestResult: { CompleteMessage: 'You do not have access to this conversation.' },
|
|
3308
|
+
});
|
|
3309
|
+
currentProvider = makeToolTurnProvider(detail);
|
|
3310
|
+
const resolver = makeAnonResolver();
|
|
3311
|
+
|
|
3312
|
+
await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
|
|
3313
|
+
|
|
3314
|
+
const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
|
|
3315
|
+
const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
|
|
3316
|
+
expect(failureLine).toBeDefined();
|
|
3317
|
+
expect(failureLine).toContain('You do not have access to this conversation.');
|
|
3318
|
+
});
|
|
3105
3319
|
});
|
|
3106
3320
|
|
|
3107
3321
|
describe('RealtimeClientSessionResolver — colleague authorization (allowedAgents)', () => {
|
|
@@ -59,7 +59,13 @@ vi.mock('@memberjunction/remote-browser-server', () => ({
|
|
|
59
59
|
}));
|
|
60
60
|
|
|
61
61
|
vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
|
|
62
|
-
|
|
62
|
+
// Spread the real module, as for ai-core-plus below, so transitively-loaded modules resolve: runners such as
|
|
63
|
+
// AIRerankerRunner (which extends BaseModelRunner), and RunDecisionResolver's AIDecisionRunner. Only
|
|
64
|
+
// AIPromptRunner is overridden for the test.
|
|
65
|
+
vi.mock('@memberjunction/ai-prompts', async (importOriginal) => ({
|
|
66
|
+
...(await importOriginal<typeof import('@memberjunction/ai-prompts')>()),
|
|
67
|
+
AIPromptRunner: class {},
|
|
68
|
+
}));
|
|
63
69
|
vi.mock('@memberjunction/ai-core-plus', async (importOriginal) => ({
|
|
64
70
|
...(await importOriginal<typeof import('@memberjunction/ai-core-plus')>()),
|
|
65
71
|
AIPromptParams: class {},
|
|
@@ -72,7 +72,13 @@ vi.mock('@memberjunction/remote-browser-server', () => ({
|
|
|
72
72
|
|
|
73
73
|
// Keep the AI imports (visual interpreter) inert — they aren't exercised by the audio path.
|
|
74
74
|
vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
|
|
75
|
-
|
|
75
|
+
// Spread the real module, as for ai-core-plus below, so transitively-loaded modules resolve: runners such as
|
|
76
|
+
// AIRerankerRunner (which extends BaseModelRunner), and RunDecisionResolver's AIDecisionRunner. Only
|
|
77
|
+
// AIPromptRunner is overridden for the test.
|
|
78
|
+
vi.mock('@memberjunction/ai-prompts', async (importOriginal) => ({
|
|
79
|
+
...(await importOriginal<typeof import('@memberjunction/ai-prompts')>()),
|
|
80
|
+
AIPromptRunner: class {},
|
|
81
|
+
}));
|
|
76
82
|
// Spread the real module so transitively-loaded code (e.g. ArtifactToolManager → DataSnapshotToolLibrary,
|
|
77
83
|
// which extends BaseArtifactToolLibrary) still resolves with its REAL base classes; only AIPromptParams is
|
|
78
84
|
// overridden for the test. (Subsumes the narrower stub-two-exports approach — any other transitive export
|
|
@@ -62,7 +62,13 @@ vi.mock('@memberjunction/remote-browser-server', () => ({
|
|
|
62
62
|
|
|
63
63
|
// Keep the AI imports (visual interpreter) inert — they aren't exercised by the snapshot path.
|
|
64
64
|
vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
|
|
65
|
-
|
|
65
|
+
// Spread the real module, as for ai-core-plus below, so transitively-loaded modules resolve: runners such as
|
|
66
|
+
// AIRerankerRunner (which extends BaseModelRunner), and RunDecisionResolver's AIDecisionRunner. Only
|
|
67
|
+
// AIPromptRunner is overridden for the test.
|
|
68
|
+
vi.mock('@memberjunction/ai-prompts', async (importOriginal) => ({
|
|
69
|
+
...(await importOriginal<typeof import('@memberjunction/ai-prompts')>()),
|
|
70
|
+
AIPromptRunner: class {},
|
|
71
|
+
}));
|
|
66
72
|
// Spread the real module so transitively-loaded code still resolves with its REAL base classes; only
|
|
67
73
|
// AIPromptParams is overridden for the test.
|
|
68
74
|
vi.mock('@memberjunction/ai-core-plus', async (importOriginal) => ({
|