@memberjunction/server 5.43.0 → 5.44.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.
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +3 -0
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +3 -1
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/generated/generated.d.ts +1159 -51
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +35284 -29068
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +74 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +211 -2
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +6 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +8 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +28 -4
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +37 -15
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.js +311 -0
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
- package/dist/resolvers/peaksSidecar.d.ts +30 -0
- package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
- package/dist/resolvers/peaksSidecar.js +51 -0
- package/dist/resolvers/peaksSidecar.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +68 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
- package/dist/rest/MediaAccessKeys.js +96 -0
- package/dist/rest/MediaAccessKeys.js.map +1 -0
- package/dist/rest/MediaStreamHandler.d.ts +26 -0
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
- package/dist/rest/MediaStreamHandler.js +192 -0
- package/dist/rest/MediaStreamHandler.js.map +1 -0
- package/dist/rest/mediaRange.d.ts +41 -0
- package/dist/rest/mediaRange.d.ts.map +1 -0
- package/dist/rest/mediaRange.js +60 -0
- package/dist/rest/mediaRange.js.map +1 -0
- package/package.json +83 -82
- package/src/__tests__/FileResolverPeaks.test.ts +64 -0
- package/src/__tests__/MediaAccessKeys.test.ts +68 -0
- package/src/__tests__/MediaStreamHandler.test.ts +91 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
- package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
- package/src/__tests__/SessionManager.test.ts +2 -0
- package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
- package/src/agentSessions/SessionManager.ts +3 -0
- package/src/agentSessions/index.ts +3 -1
- package/src/generated/generated.ts +17839 -13567
- package/src/index.ts +10 -0
- package/src/resolvers/FileResolver.ts +199 -1
- package/src/resolvers/QueryResolver.ts +33 -19
- package/src/resolvers/RealtimeBridgeResolver.ts +36 -4
- package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
- package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
- package/src/resolvers/RunAIAgentResolver.ts +44 -11
- package/src/resolvers/meetingRecordingRegistration.ts +432 -0
- package/src/resolvers/peaksSidecar.ts +52 -0
- package/src/rest/MediaAccessKeys.ts +121 -0
- package/src/rest/MediaStreamHandler.ts +220 -0
- package/src/rest/mediaRange.ts +76 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseRangeHeaderLoose, parseRange } from '../rest/mediaRange.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the pure HTTP-Range parsing helpers behind the authenticated `/media/:fileId`
|
|
6
|
+
* stream route. They decide whether a request gets a 206 partial body (and over which byte
|
|
7
|
+
* window) or a 200/416 — the load-bearing logic for native `<audio>`/`<video>` seeking. Both
|
|
8
|
+
* helpers are deliberately strict: anything malformed/multi-range yields `undefined` so the
|
|
9
|
+
* handler falls back to a full read (streaming path) or a 416 (buffer path).
|
|
10
|
+
*
|
|
11
|
+
* - `parseRangeHeaderLoose` — streaming path, NO known total: open-ended `bytes=start-` leaves
|
|
12
|
+
* `end` undefined so the driver streams to EOF.
|
|
13
|
+
* - `parseRange` — buffer path WITH a known total: clamps `end` to the last byte and rejects
|
|
14
|
+
* unsatisfiable ranges (start past EOF) so the caller returns 416.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe('parseRangeHeaderLoose (streaming path — no known total)', () => {
|
|
18
|
+
it('parses a closed range bytes=start-end into inclusive offsets', () => {
|
|
19
|
+
expect(parseRangeHeaderLoose('bytes=0-499')).toEqual({ start: 0, end: 499 });
|
|
20
|
+
expect(parseRangeHeaderLoose('bytes=200-1023')).toEqual({ start: 200, end: 1023 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('leaves end undefined for an open-ended range bytes=start- (stream to EOF)', () => {
|
|
24
|
+
expect(parseRangeHeaderLoose('bytes=500-')).toEqual({ start: 500 });
|
|
25
|
+
expect(parseRangeHeaderLoose('bytes=0-')).toEqual({ start: 0 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('tolerates surrounding whitespace', () => {
|
|
29
|
+
expect(parseRangeHeaderLoose(' bytes=10-20 ')).toEqual({ start: 10, end: 20 });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns undefined when end < start (nonsensical range)', () => {
|
|
33
|
+
expect(parseRangeHeaderLoose('bytes=500-100')).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns undefined for malformed / multi-range / suffix-only headers', () => {
|
|
37
|
+
expect(parseRangeHeaderLoose('bytes=abc-def')).toBeUndefined();
|
|
38
|
+
expect(parseRangeHeaderLoose('bytes=-500')).toBeUndefined(); // suffix range — left to a full read
|
|
39
|
+
expect(parseRangeHeaderLoose('bytes=0-100,200-300')).toBeUndefined(); // multi-range
|
|
40
|
+
expect(parseRangeHeaderLoose('items=0-100')).toBeUndefined(); // wrong unit
|
|
41
|
+
expect(parseRangeHeaderLoose('bytes=')).toBeUndefined();
|
|
42
|
+
expect(parseRangeHeaderLoose('')).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('accepts a zero-length point range bytes=N-N', () => {
|
|
46
|
+
expect(parseRangeHeaderLoose('bytes=42-42')).toEqual({ start: 42, end: 42 });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('parseRange (buffer path — known total)', () => {
|
|
51
|
+
const TOTAL = 1000;
|
|
52
|
+
|
|
53
|
+
it('parses a closed range within bounds', () => {
|
|
54
|
+
expect(parseRange('bytes=0-499', TOTAL)).toEqual({ start: 0, end: 499 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('clamps end to the last byte when the requested end exceeds total', () => {
|
|
58
|
+
// bytes=500-99999 against a 1000-byte file → end clamps to 999.
|
|
59
|
+
expect(parseRange('bytes=500-99999', TOTAL)).toEqual({ start: 500, end: 999 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('resolves an open-ended range to the last byte', () => {
|
|
63
|
+
expect(parseRange('bytes=200-', TOTAL)).toEqual({ start: 200, end: 999 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns undefined when start is at or past EOF (unsatisfiable → 416)', () => {
|
|
67
|
+
expect(parseRange('bytes=1000-1100', TOTAL)).toBeUndefined(); // start === total
|
|
68
|
+
expect(parseRange('bytes=2000-', TOTAL)).toBeUndefined(); // start > total
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns undefined when end < start after parsing', () => {
|
|
72
|
+
expect(parseRange('bytes=600-100', TOTAL)).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns undefined for malformed / multi-range / suffix-only headers', () => {
|
|
76
|
+
expect(parseRange('bytes=abc-', TOTAL)).toBeUndefined();
|
|
77
|
+
expect(parseRange('bytes=-200', TOTAL)).toBeUndefined();
|
|
78
|
+
expect(parseRange('bytes=0-100,200-300', TOTAL)).toBeUndefined();
|
|
79
|
+
expect(parseRange('items=0-100', TOTAL)).toBeUndefined();
|
|
80
|
+
expect(parseRange('', TOTAL)).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('handles the very first byte and the very last byte exactly', () => {
|
|
84
|
+
expect(parseRange('bytes=0-0', TOTAL)).toEqual({ start: 0, end: 0 });
|
|
85
|
+
expect(parseRange('bytes=999-999', TOTAL)).toEqual({ start: 999, end: 999 });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('tolerates surrounding whitespace', () => {
|
|
89
|
+
expect(parseRange(' bytes=10-20 ', TOTAL)).toEqual({ start: 10, end: 20 });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -33,6 +33,13 @@ vi.mock('@memberjunction/ai-agents', () => ({
|
|
|
33
33
|
CreateBridgeRoomTranscriptSink: vi.fn(),
|
|
34
34
|
}));
|
|
35
35
|
|
|
36
|
+
// Mock the meeting-recording registration so the thin resolver is tested in isolation (no MJStorage /
|
|
37
|
+
// core-entities graph). Its own behavior is covered by meetingRecordingRegistration.test.ts.
|
|
38
|
+
vi.mock('../resolvers/meetingRecordingRegistration', () => ({
|
|
39
|
+
registerMeetingRecordingFile: vi.fn(async () => ({ Success: true, RecordingFileID: 'file-1', ConversationID: 'conv-1' })),
|
|
40
|
+
correlateRecordingStart: vi.fn(async () => true),
|
|
41
|
+
}));
|
|
42
|
+
|
|
36
43
|
import { RealtimeBridgeResolver, MintLiveKitClientTokenInput, LiveKitRecordingInput } from '../resolvers/RealtimeBridgeResolver';
|
|
37
44
|
import type { AppContext } from '../types.js';
|
|
38
45
|
|
|
@@ -83,10 +90,12 @@ describe('RealtimeBridgeResolver', () => {
|
|
|
83
90
|
expect(result.Status).toBe('EGRESS_ACTIVE');
|
|
84
91
|
});
|
|
85
92
|
|
|
86
|
-
it('stops a recording and
|
|
93
|
+
it('stops a recording, registers the MP4, and returns the RecordingFileID', async () => {
|
|
87
94
|
const result = await resolver.StopLiveKitRecording('eg-1', ctx);
|
|
88
95
|
expect(result.Success).toBe(true);
|
|
89
96
|
expect(result.Status).toBe('EGRESS_COMPLETE');
|
|
97
|
+
// The registration mock returns a file id, which the resolver surfaces on the result.
|
|
98
|
+
expect(result.RecordingFileID).toBe('file-1');
|
|
90
99
|
});
|
|
91
100
|
|
|
92
101
|
it('requires an authenticated user to record', async () => {
|
|
@@ -2603,3 +2603,72 @@ describe('RealtimeClientSessionResolver.StartRealtimeClientSession — runtime-o
|
|
|
2603
2603
|
expect(result.EffectiveConfigJson).toBeUndefined();
|
|
2604
2604
|
});
|
|
2605
2605
|
});
|
|
2606
|
+
|
|
2607
|
+
describe('RealtimeClientSessionResolver — app awareness (applicationId / appContext / allowedAgents)', () => {
|
|
2608
|
+
it('forwards applicationId + parsed appContext to PrepareClientSession and persists the allowed union', async () => {
|
|
2609
|
+
hasPermissionMock.mockResolvedValue(true);
|
|
2610
|
+
currentProvider = makeProvider(() => makeSessionEntity());
|
|
2611
|
+
const createdSession = makeSessionEntity({ ID: 'session-app', ConversationID: 'conv-1' });
|
|
2612
|
+
createSessionMock.mockResolvedValue(createdSession);
|
|
2613
|
+
prepareClientSessionMock.mockResolvedValue({
|
|
2614
|
+
Success: true,
|
|
2615
|
+
ClientConfig: { Provider: 'openai', Model: 'm', EphemeralToken: 'ek', ExpiresAt: '2099-01-01T00:00:00Z', SessionConfig: {} },
|
|
2616
|
+
// Effective config carries the union-accumulated allowed agents (from the app's RelevantAgents).
|
|
2617
|
+
EffectiveConfig: { realtime: { allowedAgents: [{ agentId: 'skip-1', label: 'Skip' }] } },
|
|
2618
|
+
});
|
|
2619
|
+
const resolver = makeResolver();
|
|
2620
|
+
const appContext = { App: { Name: 'Data Explorer', Description: '' }, ActiveNavItem: { Name: 'Queries' }, OtherNavItems: [], User: { Name: 'A', Roles: [] } };
|
|
2621
|
+
|
|
2622
|
+
await resolver.StartRealtimeClientSession(
|
|
2623
|
+
'target-1', makeCtx(),
|
|
2624
|
+
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
|
2625
|
+
'app-9', JSON.stringify(appContext),
|
|
2626
|
+
);
|
|
2627
|
+
|
|
2628
|
+
// App context reached the prepare service.
|
|
2629
|
+
const prepArg = prepareClientSessionMock.mock.calls[0][0] as { ApplicationID?: string; AppContext?: { App?: { Name?: string } } };
|
|
2630
|
+
expect(prepArg.ApplicationID).toBe('app-9');
|
|
2631
|
+
expect(prepArg.AppContext?.App?.Name).toBe('Data Explorer');
|
|
2632
|
+
|
|
2633
|
+
// The allowed union + applicationID were persisted onto the session config for relayed-call validation.
|
|
2634
|
+
const persisted = JSON.parse(createdSession.Config_ as string) as { applicationID?: string; allowedAgents?: Array<{ agentId: string }> };
|
|
2635
|
+
expect(persisted.applicationID).toBe('app-9');
|
|
2636
|
+
expect(persisted.allowedAgents?.map(a => a.agentId)).toEqual(['skip-1']);
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
it('tolerates a malformed appContextJson (no app context, never fails the start)', async () => {
|
|
2640
|
+
hasPermissionMock.mockResolvedValue(true);
|
|
2641
|
+
currentProvider = makeProvider(() => makeSessionEntity());
|
|
2642
|
+
createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-bad' }));
|
|
2643
|
+
prepareClientSessionMock.mockResolvedValue({
|
|
2644
|
+
Success: true, ClientConfig: { Provider: 'o', Model: 'm', EphemeralToken: 'ek', ExpiresAt: '2099-01-01T00:00:00Z', SessionConfig: {} },
|
|
2645
|
+
});
|
|
2646
|
+
const resolver = makeResolver();
|
|
2647
|
+
|
|
2648
|
+
await resolver.StartRealtimeClientSession(
|
|
2649
|
+
'target-1', makeCtx(),
|
|
2650
|
+
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
|
2651
|
+
'app-9', 'not-json',
|
|
2652
|
+
);
|
|
2653
|
+
const prepArg = prepareClientSessionMock.mock.calls[0][0] as { ApplicationID?: string; AppContext?: unknown };
|
|
2654
|
+
expect(prepArg.ApplicationID).toBe('app-9');
|
|
2655
|
+
expect(prepArg.AppContext).toBeUndefined();
|
|
2656
|
+
});
|
|
2657
|
+
|
|
2658
|
+
it('passes the persisted allowedAgents from the session config into ExecuteRelayedTool', async () => {
|
|
2659
|
+
currentProvider = makeProvider(() =>
|
|
2660
|
+
makeSessionEntity({
|
|
2661
|
+
Config_: JSON.stringify({ targetAgentID: 'lead-1', allowedAgents: [{ agentId: 'skip-1', label: 'Skip' }] }),
|
|
2662
|
+
}),
|
|
2663
|
+
);
|
|
2664
|
+
executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
|
|
2665
|
+
const resolver = makeResolver();
|
|
2666
|
+
|
|
2667
|
+
await resolver.ExecuteRealtimeSessionTool(
|
|
2668
|
+
'session-1', 'call-1', 'invoke-target-agent', '{"agent":"Skip","request":"x"}', makeCtx(), makePubSub(),
|
|
2669
|
+
);
|
|
2670
|
+
|
|
2671
|
+
const relayArg = executeRelayedToolMock.mock.calls[0][0] as { AllowedAgents?: Array<{ agentId: string }> };
|
|
2672
|
+
expect(relayArg.AllowedAgents?.map(a => a.agentId)).toEqual(['skip-1']);
|
|
2673
|
+
});
|
|
2674
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HEADLESS INTEGRATION test — the Realtime Co-Agent "app awareness" stack composed
|
|
3
|
+
* end-to-end across packages: the app config cascade (`@memberjunction/ai-agents`
|
|
4
|
+
* realtime-coagent-config), the `AppContextSnapshot` mint-time injection
|
|
5
|
+
* (`@memberjunction/ai-core-plus`), the colleagues framing, and the multi-target delegation routing
|
|
6
|
+
* — all driven through the REAL {@link RealtimeClientSessionService} (only DB/SDK seams stubbed).
|
|
7
|
+
*
|
|
8
|
+
* This is the integration counterpart to the per-package unit tests: it proves the Move 1/3/4 layers
|
|
9
|
+
* actually compose (an app's AgentSettings → cascade → effective config → system prompt + allowed
|
|
10
|
+
* union → delegation), with no provider socket and no DB. Voice itself is not exercised (that is the
|
|
11
|
+
* live-verification step), but everything up to "what the model is told and which agent a delegation
|
|
12
|
+
* runs" is.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
BaseRealtimeModel,
|
|
17
|
+
ClientRealtimeSessionConfig,
|
|
18
|
+
IRealtimeSession,
|
|
19
|
+
RealtimeSessionParams,
|
|
20
|
+
} from '@memberjunction/ai';
|
|
21
|
+
import { UserInfo, IMetadataProvider } from '@memberjunction/core';
|
|
22
|
+
import { MJAIAgentEntityExtended, AppContextSnapshot, ExecuteAgentResult } from '@memberjunction/ai-core-plus';
|
|
23
|
+
import {
|
|
24
|
+
RealtimeClientSessionService,
|
|
25
|
+
PrepareClientSessionInput,
|
|
26
|
+
ExecuteRelayedToolInput,
|
|
27
|
+
RealtimeModelResolution,
|
|
28
|
+
CoAgentSystemPromptResolution,
|
|
29
|
+
} from '@memberjunction/ai-agents';
|
|
30
|
+
import {
|
|
31
|
+
BuildAppRealtimeOverridesJson,
|
|
32
|
+
INVOKE_TARGET_AGENT_TOOL_NAME,
|
|
33
|
+
DelegateToTargetRequest,
|
|
34
|
+
} from '@memberjunction/ai-agents';
|
|
35
|
+
|
|
36
|
+
class StubSession implements IRealtimeSession {
|
|
37
|
+
SendInput(): void {}
|
|
38
|
+
async RegisterTools(): Promise<void> {}
|
|
39
|
+
OnOutput(): void {}
|
|
40
|
+
OnTranscript(): void {}
|
|
41
|
+
OnToolCall(): void {}
|
|
42
|
+
async SendToolResult(): Promise<void> {}
|
|
43
|
+
OnInterruption(): void {}
|
|
44
|
+
OnUsage(): void {}
|
|
45
|
+
OnError(): void {}
|
|
46
|
+
async Close(): Promise<void> {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class StubModel extends BaseRealtimeModel {
|
|
50
|
+
constructor() { super('k'); }
|
|
51
|
+
public override get SupportsClientDirect(): boolean { return true; }
|
|
52
|
+
async StartSession(): Promise<IRealtimeSession> { return new StubSession(); }
|
|
53
|
+
public override async CreateClientSession(params: RealtimeSessionParams): Promise<ClientRealtimeSessionConfig> {
|
|
54
|
+
return {
|
|
55
|
+
Provider: 'stub', Model: params.Model, EphemeralToken: 't', ExpiresAt: '2099-01-01T00:00:00Z',
|
|
56
|
+
SessionConfig: { instructions: params.SystemPrompt },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Real service with only the DB/SDK seams stubbed; cascade + prompt + delegation routing stay REAL. */
|
|
62
|
+
class IntegrationService extends RealtimeClientSessionService {
|
|
63
|
+
private model = new StubModel();
|
|
64
|
+
public AppOverridesJson: string | null = null;
|
|
65
|
+
/** Captures which agent a delegation actually ran (the routing outcome under test). */
|
|
66
|
+
public RanAgent: MJAIAgentEntityExtended | null = null;
|
|
67
|
+
public TargetNames: Record<string, string> = { 'lead-1': 'Sage' };
|
|
68
|
+
|
|
69
|
+
protected override async configureEngine(): Promise<void> {}
|
|
70
|
+
protected override async resolveRealtimeModel(): Promise<RealtimeModelResolution | null> {
|
|
71
|
+
return { Model: this.model, ModelID: 'm', VendorID: 'v', APIName: 'stub-realtime' };
|
|
72
|
+
}
|
|
73
|
+
protected override getCoAgentSystemPromptText(): string { return 'CO-AGENT BODY'; }
|
|
74
|
+
protected override resolveCoAgentSystemPrompt(): CoAgentSystemPromptResolution {
|
|
75
|
+
return { Text: 'CO-AGENT BODY', PromptID: 'p1' };
|
|
76
|
+
}
|
|
77
|
+
protected override async createCoAgentObservabilityRun(): Promise<{ CoAgentRunID: string } | null> {
|
|
78
|
+
return { CoAgentRunID: 'run-1' };
|
|
79
|
+
}
|
|
80
|
+
protected override resolveTargetAgent(id: string): MJAIAgentEntityExtended | null {
|
|
81
|
+
if (!id) return null;
|
|
82
|
+
const name = this.TargetNames[id] ?? id;
|
|
83
|
+
return { ID: id, Name: name, Description: `${name} agent.` } as unknown as MJAIAgentEntityExtended;
|
|
84
|
+
}
|
|
85
|
+
protected override async resolveAppRealtimeOverrides(): Promise<string | null> { return this.AppOverridesJson; }
|
|
86
|
+
// Capture the routed target without running a real agent.
|
|
87
|
+
protected override async runDelegatedAgent(
|
|
88
|
+
_input: ExecuteRelayedToolInput,
|
|
89
|
+
_request: DelegateToTargetRequest,
|
|
90
|
+
target: MJAIAgentEntityExtended,
|
|
91
|
+
): Promise<ExecuteAgentResult> {
|
|
92
|
+
this.RanAgent = target;
|
|
93
|
+
return { success: true, payload: null, agentRun: { Status: 'Completed' } } as unknown as ExecuteAgentResult;
|
|
94
|
+
}
|
|
95
|
+
protected override async createDelegatedRunArtifacts(): Promise<undefined> { return undefined; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const user = { ID: 'u1' } as unknown as UserInfo;
|
|
99
|
+
const provider = {} as unknown as IMetadataProvider;
|
|
100
|
+
|
|
101
|
+
function coAgent(): MJAIAgentEntityExtended {
|
|
102
|
+
return { ID: 'co-1', Name: 'Realtime Co-Agent', InjectNotes: false, InjectExamples: false } as unknown as MJAIAgentEntityExtended;
|
|
103
|
+
}
|
|
104
|
+
function input(over: Partial<PrepareClientSessionInput> = {}): PrepareClientSessionInput {
|
|
105
|
+
return { CoAgent: coAgent(), TargetAgentID: 'lead-1', AgentSessionID: 's1', ConversationMessages: [], ...over };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('Realtime Co-Agent app awareness — integration', () => {
|
|
109
|
+
it('composes app AgentSettings → cascade → prompt (app-context + colleagues) + allowed union', async () => {
|
|
110
|
+
const svc = new IntegrationService();
|
|
111
|
+
svc.TargetNames = { 'lead-1': 'Sage', 'skip-1': 'Skip' };
|
|
112
|
+
svc.AppOverridesJson = BuildAppRealtimeOverridesJson(
|
|
113
|
+
{ Disclosure: 'mention', Persona: { Tone: 'warm', SpeakingStyle: 'concise' } },
|
|
114
|
+
[{ agentId: 'skip-1', label: 'Skip', disclosure: 'silent' }],
|
|
115
|
+
);
|
|
116
|
+
const appContext: AppContextSnapshot = {
|
|
117
|
+
App: { Name: 'Knowledge Hub', Description: '' },
|
|
118
|
+
ActiveNavItem: { Name: 'Analytics' },
|
|
119
|
+
OtherNavItems: [],
|
|
120
|
+
User: { Name: 'Amith', Roles: [] },
|
|
121
|
+
View: { FreeText: 'looking at the cluster chart' },
|
|
122
|
+
Capabilities: { Agents: [{ AgentID: 'skip-1', Name: 'Skip' }] },
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const result = await svc.PrepareClientSession(input({ AppContext: appContext }), user, provider);
|
|
126
|
+
expect(result.Success).toBe(true);
|
|
127
|
+
|
|
128
|
+
const prompt = result.SessionParams!.SystemPrompt;
|
|
129
|
+
// Identity (lead) + colleague (from app RelevantAgents) + per-target disclosure guidance.
|
|
130
|
+
expect(prompt).toContain('voice for the agent "Sage"');
|
|
131
|
+
expect(prompt).toContain('Your colleagues:');
|
|
132
|
+
expect(prompt).toContain('"Skip"');
|
|
133
|
+
expect(prompt).toContain('speak their result as your own'); // Skip's silent disclosure
|
|
134
|
+
// Persona (app override) folded in.
|
|
135
|
+
expect(prompt).toContain('Voice & manner');
|
|
136
|
+
// App-context snapshot injected at mint.
|
|
137
|
+
expect(prompt).toContain('CURRENT APP CONTEXT');
|
|
138
|
+
expect(prompt).toContain('Knowledge Hub');
|
|
139
|
+
|
|
140
|
+
// Effective config carries the union-accumulated allowed agents (surfaced to the resolver to persist).
|
|
141
|
+
expect(result.EffectiveConfig?.realtime?.allowedAgents?.map(a => a.agentId)).toContain('skip-1');
|
|
142
|
+
expect(result.EffectiveConfig?.realtime?.disclosure).toBe('mention');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('routes a relayed invoke-target-agent call to a named colleague in the allowed union', async () => {
|
|
146
|
+
const svc = new IntegrationService();
|
|
147
|
+
svc.TargetNames = { 'lead-1': 'Sage', 'skip-1': 'Skip' };
|
|
148
|
+
const relay: ExecuteRelayedToolInput = {
|
|
149
|
+
AgentSessionID: 's1',
|
|
150
|
+
TargetAgentID: 'lead-1',
|
|
151
|
+
AllowedAgents: [{ agentId: 'skip-1', label: 'Skip' }],
|
|
152
|
+
Call: {
|
|
153
|
+
CallID: 'c1',
|
|
154
|
+
ToolName: INVOKE_TARGET_AGENT_TOOL_NAME,
|
|
155
|
+
Arguments: JSON.stringify({ agent: 'Skip', request: 'analyze this' }),
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const out = await svc.ExecuteRelayedTool(relay, user, provider);
|
|
159
|
+
expect(JSON.parse(out.ResultJson).success).toBe(true);
|
|
160
|
+
expect(svc.RanAgent?.ID).toBe('skip-1'); // routed to the named colleague, not the lead
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('routes to the lead when no colleague is named', async () => {
|
|
164
|
+
const svc = new IntegrationService();
|
|
165
|
+
const relay: ExecuteRelayedToolInput = {
|
|
166
|
+
AgentSessionID: 's1',
|
|
167
|
+
TargetAgentID: 'lead-1',
|
|
168
|
+
AllowedAgents: [{ agentId: 'skip-1', label: 'Skip' }],
|
|
169
|
+
Call: { CallID: 'c1', ToolName: INVOKE_TARGET_AGENT_TOOL_NAME, Arguments: JSON.stringify({ request: 'do it' }) },
|
|
170
|
+
};
|
|
171
|
+
await svc.ExecuteRelayedTool(relay, user, provider);
|
|
172
|
+
expect(svc.RanAgent?.ID).toBe('lead-1');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('returns a structured not-available error for an unknown colleague', async () => {
|
|
176
|
+
const svc = new IntegrationService();
|
|
177
|
+
const relay: ExecuteRelayedToolInput = {
|
|
178
|
+
AgentSessionID: 's1',
|
|
179
|
+
TargetAgentID: 'lead-1',
|
|
180
|
+
AllowedAgents: [{ agentId: 'skip-1', label: 'Skip' }],
|
|
181
|
+
Call: { CallID: 'c1', ToolName: INVOKE_TARGET_AGENT_TOOL_NAME, Arguments: JSON.stringify({ agent: 'Ghost', request: 'x' }) },
|
|
182
|
+
};
|
|
183
|
+
const out = await svc.ExecuteRelayedTool(relay, user, provider);
|
|
184
|
+
const parsed = JSON.parse(out.ResultJson);
|
|
185
|
+
expect(parsed.success).toBe(false);
|
|
186
|
+
// A not-available colleague is a structured delegation outcome ({success:false, output}),
|
|
187
|
+
// not a thrown error — the model narrates `output` to self-correct.
|
|
188
|
+
expect(parsed.output).toContain('Ghost');
|
|
189
|
+
expect(svc.RanAgent).toBeNull(); // nothing ran
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// type-graphql decorators on the resolver call `Reflect.getMetadata`, which only exists when this
|
|
2
|
+
// polyfill is loaded first. 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
|
+
// type-graphql's `@Field()`/`@ObjectType()`/`@Query()` decorators need `emitDecoratorMetadata`
|
|
8
|
+
// reflection (a build-time tsc feature) that vitest's esbuild transform does NOT apply, so importing the
|
|
9
|
+
// decorated resolver throws "Unable to infer GraphQL type". This unit test exercises the resolver's plain
|
|
10
|
+
// TS logic (the snapshot best-effort contract), not its GraphQL schema — so we replace the decorators with
|
|
11
|
+
// no-ops. (The real schema is validated by the build + integration tests.)
|
|
12
|
+
vi.mock('type-graphql', () => {
|
|
13
|
+
const noopDecorator = () => () => undefined;
|
|
14
|
+
return {
|
|
15
|
+
Resolver: noopDecorator,
|
|
16
|
+
Mutation: noopDecorator,
|
|
17
|
+
Query: noopDecorator,
|
|
18
|
+
Subscription: noopDecorator,
|
|
19
|
+
ObjectType: noopDecorator,
|
|
20
|
+
InputType: noopDecorator,
|
|
21
|
+
Field: noopDecorator,
|
|
22
|
+
Arg: noopDecorator,
|
|
23
|
+
Args: noopDecorator,
|
|
24
|
+
Ctx: noopDecorator,
|
|
25
|
+
PubSub: noopDecorator,
|
|
26
|
+
Root: noopDecorator,
|
|
27
|
+
Float: class {},
|
|
28
|
+
Int: class {},
|
|
29
|
+
ID: class {},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// --- Mock the live-session engine the resolver reads. A single mutable session object lets each test
|
|
34
|
+
// script what CaptureScreenshot does (resolve a frame / throw "Browser not launched"). ---
|
|
35
|
+
interface FakeLiveSession {
|
|
36
|
+
CaptureScreenshot: ReturnType<typeof vi.fn>;
|
|
37
|
+
GetCurrentUrl: ReturnType<typeof vi.fn>;
|
|
38
|
+
}
|
|
39
|
+
const liveSession: FakeLiveSession = {
|
|
40
|
+
CaptureScreenshot: vi.fn(async () => 'QUJD'),
|
|
41
|
+
GetCurrentUrl: vi.fn(() => 'https://www.wikipedia.org/'),
|
|
42
|
+
};
|
|
43
|
+
// The snapshot path is a pure READ: it looks up an existing session via GetSessionForAgentSession and never
|
|
44
|
+
// lazily starts one. `undefined` models "no live browser for this agent session".
|
|
45
|
+
const getSessionMock = vi.fn<[], FakeLiveSession | undefined>(() => liveSession);
|
|
46
|
+
vi.mock('@memberjunction/remote-browser-server', () => ({
|
|
47
|
+
RemoteBrowserEngine: {
|
|
48
|
+
Instance: {
|
|
49
|
+
GetSessionForAgentSession: (...args: unknown[]) => getSessionMock(...(args as [])),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// Keep the AI imports (visual interpreter) inert — they aren't exercised by the snapshot path.
|
|
55
|
+
vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
|
|
56
|
+
vi.mock('@memberjunction/ai-prompts', () => ({ AIPromptRunner: class {} }));
|
|
57
|
+
// Spread the real module so transitively-loaded code still resolves with its REAL base classes; only
|
|
58
|
+
// AIPromptParams is overridden for the test.
|
|
59
|
+
vi.mock('@memberjunction/ai-core-plus', async (importOriginal) => ({
|
|
60
|
+
...(await importOriginal<typeof import('@memberjunction/ai-core-plus')>()),
|
|
61
|
+
AIPromptParams: class {},
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
import { RemoteBrowserActionResolver } from '../resolvers/RemoteBrowserActionResolver.js';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A test subclass that bypasses the ownership/provider plumbing (covered elsewhere) so the snapshot
|
|
68
|
+
* best-effort contract is exercised in isolation.
|
|
69
|
+
*/
|
|
70
|
+
class TestableResolver extends RemoteBrowserActionResolver {
|
|
71
|
+
protected requireUserAndProvider() {
|
|
72
|
+
return { contextUser: { ID: 'user-1' }, provider: {} } as never;
|
|
73
|
+
}
|
|
74
|
+
protected async loadOwnedSession() {
|
|
75
|
+
return { ID: 'sess-1', AgentID: 'agent-1', UserID: 'user-1' } as never;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ctx = { userPayload: { sessionId: 'push-sess-1' }, providers: {} } as never;
|
|
80
|
+
|
|
81
|
+
describe('RemoteBrowserActionResolver — snapshot (best-effort perception poll)', () => {
|
|
82
|
+
let resolver: TestableResolver;
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
resolver = new TestableResolver();
|
|
86
|
+
liveSession.CaptureScreenshot.mockReset().mockResolvedValue('QUJD');
|
|
87
|
+
liveSession.GetCurrentUrl.mockReset().mockReturnValue('https://www.wikipedia.org/');
|
|
88
|
+
getSessionMock.mockReset().mockReturnValue(liveSession);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns the screenshot + URL when the browser is live', async () => {
|
|
92
|
+
const result = await resolver.RemoteBrowserSnapshot('sess-1', ctx);
|
|
93
|
+
expect(result).toEqual({ ScreenshotBase64: 'QUJD', CurrentUrl: 'https://www.wikipedia.org/' });
|
|
94
|
+
expect(liveSession.CaptureScreenshot).toHaveBeenCalledTimes(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns an empty snapshot when no live browser exists (never started / already torn down)', async () => {
|
|
98
|
+
getSessionMock.mockReturnValue(undefined);
|
|
99
|
+
const result = await resolver.RemoteBrowserSnapshot('sess-1', ctx);
|
|
100
|
+
expect(result).toEqual({});
|
|
101
|
+
expect(liveSession.CaptureScreenshot).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// The regression the fix targets: a session handle survives in the live map but its underlying browser
|
|
105
|
+
// adapter has been torn down, so CaptureScreenshot throws "Browser not launched". The ~700ms client poll
|
|
106
|
+
// must NOT surface that as a recurring GraphQL error — the query's documented contract is empty fields,
|
|
107
|
+
// not an error. So the resolver catches and degrades to {} (the surface keeps its last good frame).
|
|
108
|
+
it('degrades to an empty snapshot — does NOT throw — when the adapter is torn down', async () => {
|
|
109
|
+
liveSession.CaptureScreenshot.mockRejectedValueOnce(
|
|
110
|
+
new Error('Browser not launched. Call Launch() before using the adapter.'),
|
|
111
|
+
);
|
|
112
|
+
const result = await resolver.RemoteBrowserSnapshot('sess-1', ctx);
|
|
113
|
+
expect(result).toEqual({});
|
|
114
|
+
expect(liveSession.CaptureScreenshot).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not read the URL when the capture fails (no half-populated snapshot)', async () => {
|
|
118
|
+
liveSession.CaptureScreenshot.mockRejectedValueOnce(new Error('Target page, context or browser has been closed'));
|
|
119
|
+
const result = await resolver.RemoteBrowserSnapshot('sess-1', ctx);
|
|
120
|
+
expect(result).toEqual({});
|
|
121
|
+
expect(liveSession.GetCurrentUrl).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -397,6 +397,8 @@ describe('SessionManager — server-side channel plugin lifecycle notifications'
|
|
|
397
397
|
AgentID: 'agent-1',
|
|
398
398
|
UserID: 'user-1',
|
|
399
399
|
ConversationID: 'conv-existing',
|
|
400
|
+
// Verbatim per-session config blob handed to data-aware channels; null when unset.
|
|
401
|
+
AgentSessionConfig: null,
|
|
400
402
|
});
|
|
401
403
|
expect((user as { ID: string }).ID).toBe('user-1');
|
|
402
404
|
expect(prov).toBe(provider);
|