@memberjunction/server 5.41.0 → 5.42.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/index.d.ts +1 -0
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +5 -0
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/agentSessions/remoteBrowserGoalEngine.d.ts +102 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.d.ts.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.js +164 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.js.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts +66 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.js +86 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.js.map +1 -0
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +4 -7
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -2
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +591 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +6832 -3610
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -16
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +108 -0
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -0
- package/dist/integration/CustomColumnPromoter.js +508 -0
- package/dist/integration/CustomColumnPromoter.js.map +1 -0
- package/dist/logging/StartupLogger.d.ts +57 -1
- package/dist/logging/StartupLogger.d.ts.map +1 -1
- package/dist/logging/StartupLogger.js +115 -6
- package/dist/logging/StartupLogger.js.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +27 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.js +136 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +101 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +544 -42
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +121 -0
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -0
- package/dist/resolvers/RealtimeBridgeResolver.js +533 -0
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +78 -5
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +227 -15
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +0 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +5 -3
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/package.json +83 -78
- package/src/__tests__/RealtimeBridgeResolver.test.ts +100 -0
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +8 -1
- package/src/__tests__/RemoteBrowserGoalEngine.test.ts +144 -0
- package/src/__tests__/remoteBrowserGoalRegistry.test.ts +81 -0
- package/src/agentSessions/index.ts +5 -0
- package/src/agentSessions/remoteBrowserGoalEngine.ts +191 -0
- package/src/agentSessions/remoteBrowserGoalRegistry.ts +122 -0
- package/src/auth/initializeProviders.ts +4 -7
- package/src/config.ts +13 -2
- package/src/generated/generated.ts +2444 -205
- package/src/index.ts +33 -16
- package/src/integration/CustomColumnPromoter.ts +597 -0
- package/src/logging/StartupLogger.ts +130 -6
- package/src/resolvers/ExecuteRemoteOperationResolver.ts +108 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +396 -37
- package/src/resolvers/RealtimeBridgeResolver.ts +450 -0
- package/src/resolvers/RemoteBrowserActionResolver.ts +232 -17
- package/src/resolvers/RunAIAgentResolver.ts +1 -9
- package/src/rest/setupRESTEndpoints.ts +5 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// type-graphql decorators on the resolver call Reflect.getMetadata, which only exists when this polyfill
|
|
2
|
+
// is loaded first. Vitest does not bring it in automatically — this MUST precede the resolver import.
|
|
3
|
+
import 'reflect-metadata';
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import type { UserInfo } from '@memberjunction/core';
|
|
7
|
+
|
|
8
|
+
// Mock the server-side LiveKit services so the thin resolver is tested in isolation (no LiveKit creds).
|
|
9
|
+
const h = vi.hoisted(() => ({
|
|
10
|
+
mintClientToken: vi.fn(async (room: string, identity: string) => ({
|
|
11
|
+
ServerUrl: 'wss://x.livekit.cloud',
|
|
12
|
+
Token: `jwt-${identity}`,
|
|
13
|
+
Identity: identity,
|
|
14
|
+
RoomName: room,
|
|
15
|
+
})),
|
|
16
|
+
startRecording: vi.fn(async () => ({ EgressID: 'eg-1', RoomName: 'room-1', Status: 'EGRESS_ACTIVE' })),
|
|
17
|
+
stopRecording: vi.fn(async () => ({ EgressID: 'eg-1', RoomName: 'room-1', Status: 'EGRESS_COMPLETE' })),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@memberjunction/livekit-room-server', () => ({
|
|
21
|
+
LiveKitTokenService: vi.fn(() => ({ MintClientToken: h.mintClientToken })),
|
|
22
|
+
// SetSessionFactory is exercised by the resolver's module-load binding of the realtime-session factory.
|
|
23
|
+
LiveKitAgentRoomCoordinator: { Instance: { StartAgentRoomSession: vi.fn(), SetSessionFactory: vi.fn() } },
|
|
24
|
+
LiveKitEgressService: vi.fn(() => ({ StartRoomRecording: h.startRecording, StopRecording: h.stopRecording })),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// Mock the agent factory so importing the resolver doesn't pull the heavy @memberjunction/ai-agents graph
|
|
28
|
+
// into this thin resolver test (the binding only needs the symbol to exist).
|
|
29
|
+
vi.mock('@memberjunction/ai-agents', () => ({
|
|
30
|
+
CreateBridgeRealtimeSession: vi.fn(),
|
|
31
|
+
FinalizeBridgeCoAgentRuns: vi.fn(),
|
|
32
|
+
GetRealtimeModelVoices: vi.fn(),
|
|
33
|
+
CreateBridgeRoomTranscriptSink: vi.fn(),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import { RealtimeBridgeResolver, MintLiveKitClientTokenInput, LiveKitRecordingInput } from '../resolvers/RealtimeBridgeResolver';
|
|
37
|
+
import type { AppContext } from '../types.js';
|
|
38
|
+
|
|
39
|
+
/** A resolver subclass that supplies a fake authenticated user (GetUserFromPayload is protected). */
|
|
40
|
+
class TestableResolver extends RealtimeBridgeResolver {
|
|
41
|
+
public user: UserInfo | undefined = { ID: 'U1', Name: 'Amith', Email: 'amith@x.com' } as unknown as UserInfo;
|
|
42
|
+
protected override GetUserFromPayload(): UserInfo | undefined {
|
|
43
|
+
return this.user;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ctx = {} as AppContext;
|
|
48
|
+
|
|
49
|
+
describe('RealtimeBridgeResolver', () => {
|
|
50
|
+
let resolver: TestableResolver;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
resolver = new TestableResolver();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('MintLiveKitClientToken', () => {
|
|
57
|
+
it('derives the participant identity from the user and returns the token', async () => {
|
|
58
|
+
const input = Object.assign(new MintLiveKitClientTokenInput(), { RoomName: 'room-1', DisplayName: 'Amith' });
|
|
59
|
+
const result = await resolver.MintLiveKitClientToken(input, ctx);
|
|
60
|
+
expect(result.Success).toBe(true);
|
|
61
|
+
expect(result.Identity).toBe('user-u1'); // lowercased user-${ID}
|
|
62
|
+
expect(result.Token).toBe('jwt-user-u1');
|
|
63
|
+
expect(result.RoomName).toBe('room-1');
|
|
64
|
+
expect(h.mintClientToken).toHaveBeenCalledWith('room-1', 'user-u1', 'Amith');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('fails cleanly when there is no authenticated user', async () => {
|
|
68
|
+
resolver.user = undefined;
|
|
69
|
+
const input = Object.assign(new MintLiveKitClientTokenInput(), { RoomName: 'room-1' });
|
|
70
|
+
const result = await resolver.MintLiveKitClientToken(input, ctx);
|
|
71
|
+
expect(result.Success).toBe(false);
|
|
72
|
+
expect(result.ErrorMessage).toMatch(/current user/i);
|
|
73
|
+
expect(result.RoomName).toBe('room-1');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('recording', () => {
|
|
78
|
+
it('starts a recording and maps the result', async () => {
|
|
79
|
+
const input = Object.assign(new LiveKitRecordingInput(), { RoomName: 'room-1', Layout: 'grid' });
|
|
80
|
+
const result = await resolver.StartLiveKitRecording(input, ctx);
|
|
81
|
+
expect(result.Success).toBe(true);
|
|
82
|
+
expect(result.EgressID).toBe('eg-1');
|
|
83
|
+
expect(result.Status).toBe('EGRESS_ACTIVE');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('stops a recording and maps the result', async () => {
|
|
87
|
+
const result = await resolver.StopLiveKitRecording('eg-1', ctx);
|
|
88
|
+
expect(result.Success).toBe(true);
|
|
89
|
+
expect(result.Status).toBe('EGRESS_COMPLETE');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('requires an authenticated user to record', async () => {
|
|
93
|
+
resolver.user = undefined;
|
|
94
|
+
const input = Object.assign(new LiveKitRecordingInput(), { RoomName: 'room-1' });
|
|
95
|
+
const result = await resolver.StartLiveKitRecording(input, ctx);
|
|
96
|
+
expect(result.Success).toBe(false);
|
|
97
|
+
expect(result.ErrorMessage).toMatch(/current user/i);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -55,7 +55,14 @@ vi.mock('@memberjunction/remote-browser-server', () => ({
|
|
|
55
55
|
// Keep the AI imports (visual interpreter) inert — they aren't exercised by the audio path.
|
|
56
56
|
vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
|
|
57
57
|
vi.mock('@memberjunction/ai-prompts', () => ({ AIPromptRunner: class {} }));
|
|
58
|
-
|
|
58
|
+
// Spread the real module so transitively-loaded code (e.g. ArtifactToolManager → DataSnapshotToolLibrary,
|
|
59
|
+
// which extends BaseArtifactToolLibrary) still resolves with its REAL base classes; only AIPromptParams is
|
|
60
|
+
// overridden for the test. (Subsumes the narrower stub-two-exports approach — any other transitive export
|
|
61
|
+
// is supplied by the real module rather than coming back undefined.)
|
|
62
|
+
vi.mock('@memberjunction/ai-core-plus', async (importOriginal) => ({
|
|
63
|
+
...(await importOriginal<typeof import('@memberjunction/ai-core-plus')>()),
|
|
64
|
+
AIPromptParams: class {},
|
|
65
|
+
}));
|
|
59
66
|
|
|
60
67
|
import { RemoteBrowserActionResolver } from '../resolvers/RemoteBrowserActionResolver.js';
|
|
61
68
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import type { UserInfo } from '@memberjunction/core';
|
|
3
|
+
import { RunComputerUseParams, type StepRecord } from '@memberjunction/computer-use';
|
|
4
|
+
import { MJComputerUseEngine, MJRunComputerUseParams } from '@memberjunction/computer-use-engine';
|
|
5
|
+
import { CdpRemoteBrowserSession, type ComputerUseGoalRun } from '@memberjunction/remote-browser-cdp';
|
|
6
|
+
import type { MJAIAgentRunStepEntity } from '@memberjunction/core-entities';
|
|
7
|
+
import {
|
|
8
|
+
buildMJGoalParams,
|
|
9
|
+
BindRemoteBrowserGoalEngine,
|
|
10
|
+
MJProgressComputerUseEngine,
|
|
11
|
+
extractCoAgentRunID,
|
|
12
|
+
finalizeBrowserGoalStep,
|
|
13
|
+
} from '../agentSessions/remoteBrowserGoalEngine.js';
|
|
14
|
+
|
|
15
|
+
const USER = { ID: 'u-1', Email: 'amith@bluecypress.io' } as unknown as UserInfo;
|
|
16
|
+
|
|
17
|
+
/** Builds a base RunComputerUseParams the CDP session would hand to the goal engine. */
|
|
18
|
+
function baseParams(): RunComputerUseParams {
|
|
19
|
+
return Object.assign(new RunComputerUseParams(), { Goal: 'log in', MaxSteps: 7, StartUrl: 'https://app.test/' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('buildMJGoalParams', () => {
|
|
23
|
+
it('produces MJ-aware params that carry over the base goal/step/url fields', () => {
|
|
24
|
+
const mj = buildMJGoalParams(baseParams(), USER);
|
|
25
|
+
expect(mj).toBeInstanceOf(MJRunComputerUseParams);
|
|
26
|
+
expect(mj.Goal).toBe('log in');
|
|
27
|
+
expect(mj.MaxSteps).toBe(7);
|
|
28
|
+
expect(mj.StartUrl).toBe('https://app.test/');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('injects the acting ContextUser so MJ prompts run as that user', () => {
|
|
32
|
+
expect(buildMJGoalParams(baseParams(), USER).ContextUser).toBe(USER);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('leaves ContextUser undefined when none is supplied', () => {
|
|
36
|
+
expect(buildMJGoalParams(baseParams()).ContextUser).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('threads the parent run + step ids for observability nesting', () => {
|
|
40
|
+
const mj = buildMJGoalParams(baseParams(), USER, 'coagent-run-1', 'goal-step-1');
|
|
41
|
+
expect(mj.AgentRunId).toBe('coagent-run-1');
|
|
42
|
+
expect(mj.AgentRunStepID).toBe('goal-step-1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('leaves the run/step ids undefined when not supplied', () => {
|
|
46
|
+
const mj = buildMJGoalParams(baseParams(), USER);
|
|
47
|
+
expect(mj.AgentRunId).toBeUndefined();
|
|
48
|
+
expect(mj.AgentRunStepID).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('extractCoAgentRunID', () => {
|
|
53
|
+
it('pulls coAgentRunID out of a session Config_ blob', () => {
|
|
54
|
+
expect(extractCoAgentRunID(JSON.stringify({ coAgentRunID: 'run-9', promptRunID: 'p' }))).toBe('run-9');
|
|
55
|
+
});
|
|
56
|
+
it('returns undefined for a missing key, null/empty config, or non-string value', () => {
|
|
57
|
+
expect(extractCoAgentRunID(JSON.stringify({ promptRunID: 'p' }))).toBeUndefined();
|
|
58
|
+
expect(extractCoAgentRunID(null)).toBeUndefined();
|
|
59
|
+
expect(extractCoAgentRunID(undefined)).toBeUndefined();
|
|
60
|
+
expect(extractCoAgentRunID('')).toBeUndefined();
|
|
61
|
+
expect(extractCoAgentRunID(JSON.stringify({ coAgentRunID: 123 }))).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
it('returns undefined for malformed JSON (best-effort)', () => {
|
|
64
|
+
expect(extractCoAgentRunID('{not json')).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('finalizeBrowserGoalStep', () => {
|
|
69
|
+
function fakeStep() {
|
|
70
|
+
return {
|
|
71
|
+
StartedAt: new Date(),
|
|
72
|
+
Save: vi.fn(async () => true),
|
|
73
|
+
LatestResult: { CompleteMessage: '' },
|
|
74
|
+
} as unknown as MJAIAgentRunStepEntity & { Save: ReturnType<typeof vi.fn> };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it('finalizes the parent step Completed from a successful goal result + saves', async () => {
|
|
78
|
+
const step = fakeStep();
|
|
79
|
+
await finalizeBrowserGoalStep(step, { Success: true, Strategy: 'ComputerUse', Status: 'Completed', StepCount: 4, CurrentUrl: 'https://done/' });
|
|
80
|
+
expect(step.Status).toBe('Completed');
|
|
81
|
+
expect(step.Success).toBe(true);
|
|
82
|
+
expect((step as unknown as { Save: ReturnType<typeof vi.fn> }).Save).toHaveBeenCalledOnce();
|
|
83
|
+
const out = JSON.parse(step.OutputData as string);
|
|
84
|
+
expect(out.strategy).toBe('ComputerUse');
|
|
85
|
+
expect(out.stepCount).toBe(4);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('finalizes Failed and carries the detail as the error message', async () => {
|
|
89
|
+
const step = fakeStep();
|
|
90
|
+
await finalizeBrowserGoalStep(step, { Success: false, Status: 'Error', Detail: 'login blocked' });
|
|
91
|
+
expect(step.Status).toBe('Failed');
|
|
92
|
+
expect(step.ErrorMessage).toBe('login blocked');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is a no-op when the step is null (no co-agent run to nest under)', async () => {
|
|
96
|
+
await expect(finalizeBrowserGoalStep(null, { Success: true })).resolves.toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('MJProgressComputerUseEngine', () => {
|
|
101
|
+
it('is an MJ computer-use engine that satisfies the goal-run seam', () => {
|
|
102
|
+
const engine = new MJProgressComputerUseEngine();
|
|
103
|
+
expect(engine).toBeInstanceOf(MJComputerUseEngine);
|
|
104
|
+
// Seam surface the CDP session drives:
|
|
105
|
+
expect(typeof engine.SetBrowserAdapter).toBe('function');
|
|
106
|
+
expect(typeof engine.Stop).toBe('function');
|
|
107
|
+
expect(typeof engine.Run).toBe('function');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('forwards a model-safe progress note on each completed step', () => {
|
|
111
|
+
const engine = new MJProgressComputerUseEngine();
|
|
112
|
+
const notes: { Step: number; Message: string; Url?: string }[] = [];
|
|
113
|
+
engine.OnProgress = (p) => notes.push(p);
|
|
114
|
+
|
|
115
|
+
// onStepComplete is protected; the base MJ media-persistence path is a no-op here (no prompt-run id /
|
|
116
|
+
// context user set), so this exercises only the progress forwarding.
|
|
117
|
+
const step = { StepNumber: 2, ControllerReasoning: 'typing username', Url: 'https://app.test/login', Screenshot: '' } as StepRecord;
|
|
118
|
+
(engine as unknown as { onStepComplete(s: StepRecord, p: MJRunComputerUseParams): void }).onStepComplete(step, new MJRunComputerUseParams());
|
|
119
|
+
|
|
120
|
+
expect(notes).toEqual([{ Step: 2, Message: 'typing username', Url: 'https://app.test/login' }]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('BindRemoteBrowserGoalEngine', () => {
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
vi.restoreAllMocks();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('binds a factory that yields an MJ progress engine', () => {
|
|
130
|
+
const spy = vi.spyOn(CdpRemoteBrowserSession, 'SetGoalEngineFactory');
|
|
131
|
+
BindRemoteBrowserGoalEngine();
|
|
132
|
+
|
|
133
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
134
|
+
const factory = spy.mock.calls[0][0] as () => ComputerUseGoalRun;
|
|
135
|
+
expect(factory()).toBeInstanceOf(MJProgressComputerUseEngine);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('never throws (a binding hiccup must not abort server boot)', () => {
|
|
139
|
+
vi.spyOn(CdpRemoteBrowserSession, 'SetGoalEngineFactory').mockImplementation(() => {
|
|
140
|
+
throw new Error('boom');
|
|
141
|
+
});
|
|
142
|
+
expect(() => BindRemoteBrowserGoalEngine()).not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { RemoteBrowserGoalRegistry } from '../agentSessions/remoteBrowserGoalRegistry.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The registry is a process singleton; each test uses a UNIQUE session id so tracked state never
|
|
6
|
+
* bleeds across tests (no reset hook needed for the functional cases).
|
|
7
|
+
*/
|
|
8
|
+
let seq = 0;
|
|
9
|
+
const newSession = () => `sess-${++seq}`;
|
|
10
|
+
|
|
11
|
+
describe('RemoteBrowserGoalRegistry', () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('reports a freshly-begun run as Running with no outcome', () => {
|
|
17
|
+
const reg = RemoteBrowserGoalRegistry.Instance;
|
|
18
|
+
const s = newSession();
|
|
19
|
+
reg.Begin(s, 'g1');
|
|
20
|
+
const rec = reg.Get(s, 'g1');
|
|
21
|
+
expect(rec?.Status).toBe('Running');
|
|
22
|
+
expect(rec?.Outcome).toBeUndefined();
|
|
23
|
+
expect(rec?.GoalRunID).toBe('g1');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('is case-insensitive on the session id', () => {
|
|
27
|
+
const reg = RemoteBrowserGoalRegistry.Instance;
|
|
28
|
+
reg.Begin('AbC-Session', 'g1');
|
|
29
|
+
expect(reg.Get('abc-session', 'g1')?.Status).toBe('Running');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns the terminal outcome after Complete', () => {
|
|
33
|
+
const reg = RemoteBrowserGoalRegistry.Instance;
|
|
34
|
+
const s = newSession();
|
|
35
|
+
reg.Begin(s, 'g1');
|
|
36
|
+
reg.Complete(s, 'g1', { Success: true, Status: 'Completed', StepCount: 4, CurrentUrl: 'https://x', Detail: 'done' });
|
|
37
|
+
const rec = reg.Get(s, 'g1');
|
|
38
|
+
expect(rec?.Status).toBe('Complete');
|
|
39
|
+
expect(rec?.Outcome).toMatchObject({ Success: true, Status: 'Completed', StepCount: 4 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('ignores a Complete for a superseded/mismatched goalRunID', () => {
|
|
43
|
+
const reg = RemoteBrowserGoalRegistry.Instance;
|
|
44
|
+
const s = newSession();
|
|
45
|
+
reg.Begin(s, 'g2'); // a newer goal replaced g1
|
|
46
|
+
reg.Complete(s, 'g1', { Success: true, Status: 'Completed' }); // stale completion — dropped
|
|
47
|
+
const rec = reg.Get(s, 'g2');
|
|
48
|
+
expect(rec?.Status).toBe('Running');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('Get with a mismatched goalRunID returns undefined (poll for expired/superseded id reads nothing)', () => {
|
|
52
|
+
const reg = RemoteBrowserGoalRegistry.Instance;
|
|
53
|
+
const s = newSession();
|
|
54
|
+
reg.Begin(s, 'g1');
|
|
55
|
+
expect(reg.Get(s, 'g-other')).toBeUndefined();
|
|
56
|
+
expect(reg.Get(s, 'g1')).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('Begin replaces any prior run for the same session', () => {
|
|
60
|
+
const reg = RemoteBrowserGoalRegistry.Instance;
|
|
61
|
+
const s = newSession();
|
|
62
|
+
reg.Begin(s, 'g1');
|
|
63
|
+
reg.Complete(s, 'g1', { Success: true, Status: 'Completed' });
|
|
64
|
+
reg.Begin(s, 'g2'); // new goal supersedes the completed one
|
|
65
|
+
expect(reg.Get(s, 'g1')).toBeUndefined();
|
|
66
|
+
expect(reg.Get(s, 'g2')?.Status).toBe('Running');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('sweeps a completed record after its retention TTL', () => {
|
|
70
|
+
vi.useFakeTimers();
|
|
71
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
72
|
+
const reg = RemoteBrowserGoalRegistry.Instance;
|
|
73
|
+
const s = newSession();
|
|
74
|
+
reg.Begin(s, 'g1');
|
|
75
|
+
reg.Complete(s, 'g1', { Success: true, Status: 'Completed' });
|
|
76
|
+
expect(reg.Get(s, 'g1')?.Status).toBe('Complete');
|
|
77
|
+
// Past the completed-retention TTL (5 min) — the next Get sweeps it.
|
|
78
|
+
vi.advanceTimersByTime(6 * 60 * 1000);
|
|
79
|
+
expect(reg.Get(s, 'g1')).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { LoadWhiteboardChannelServer, LoadMeetingControlsChannelServer } from '@memberjunction/ai-agents';
|
|
11
11
|
import { LoadRemoteBrowserChannel } from '@memberjunction/remote-browser-server';
|
|
12
12
|
import { LoadSelfHostRemoteBrowser } from '@memberjunction/remote-browser-selfhost';
|
|
13
|
+
import { BindRemoteBrowserGoalEngine } from './remoteBrowserGoalEngine.js';
|
|
13
14
|
|
|
14
15
|
// Tree-shaking prevention: force the server-side channel plugin registrations
|
|
15
16
|
// (`@RegisterClass(BaseRealtimeChannelServer, ...)`) to execute on any static path that touches
|
|
@@ -21,7 +22,11 @@ LoadMeetingControlsChannelServer();
|
|
|
21
22
|
// Playwright — pulled in transitively through the SelfHost package, documented and acceptable).
|
|
22
23
|
LoadRemoteBrowserChannel();
|
|
23
24
|
LoadSelfHostRemoteBrowser();
|
|
25
|
+
// Bind goal-driven browser control to MJ's computer-use engine (vision-model auto-selection, prompt-run
|
|
26
|
+
// logging, credential resolution). Without this, RunComputerUseGoal falls back to the bare base engine.
|
|
27
|
+
BindRemoteBrowserGoalEngine();
|
|
24
28
|
|
|
29
|
+
export * from './remoteBrowserGoalEngine.js';
|
|
25
30
|
export * from './HostInstance.js';
|
|
26
31
|
export * from './SessionManager.js';
|
|
27
32
|
export * from './SessionJanitor.js';
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview **Production binding** of the Remote Browser goal-engine seam to MJ's computer-use engine.
|
|
3
|
+
*
|
|
4
|
+
* The CDP remote-browser session ({@link CdpRemoteBrowserSession}) runs goal-driven browser control through
|
|
5
|
+
* an injectable {@link ComputerUseGoalRun} factory. By default that factory yields a bare
|
|
6
|
+
* `ProgressComputerUseEngine` (base computer-use), which has no model auto-selection — so it can't run a goal
|
|
7
|
+
* unsupervised. This module binds the factory to {@link MJProgressComputerUseEngine}: an MJ-aware engine that
|
|
8
|
+
* routes controller/judge prompts through `AIPromptRunner`, auto-selects a vision-capable model from MJ
|
|
9
|
+
* metadata, persists step media, AND forwards per-step progress for live narration.
|
|
10
|
+
*
|
|
11
|
+
* Called once at server startup from `agentSessions/index.ts` (the realtime composition point), so every
|
|
12
|
+
* goal-driven run on this process uses the MJ engine. Kept here (an application package), not in any library,
|
|
13
|
+
* because it is the seam where the transport-neutral CDP layer is wired to MJ's AI stack.
|
|
14
|
+
*
|
|
15
|
+
* @module @memberjunction/server
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { UserInfo, LogError, IMetadataProvider, RunView } from '@memberjunction/core';
|
|
19
|
+
import { MJAIAgentRunStepEntity } from '@memberjunction/core-entities';
|
|
20
|
+
import { initAgentRunStep, finalizeAgentRunStep } from '@memberjunction/ai-core-plus';
|
|
21
|
+
import type { ComputerUseResult, RunComputerUseParams, StepRecord } from '@memberjunction/computer-use';
|
|
22
|
+
import { MJComputerUseEngine, MJRunComputerUseParams } from '@memberjunction/computer-use-engine';
|
|
23
|
+
import type { RemoteBrowserGoalResult } from '@memberjunction/remote-browser-base';
|
|
24
|
+
import { buildProgressNote, CdpRemoteBrowserSession, type ComputerUseGoalProgress, type ComputerUseGoalRun } from '@memberjunction/remote-browser-cdp';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Adapts the transport-neutral base computer-use params into {@link MJRunComputerUseParams}, injecting the
|
|
28
|
+
* acting user so the MJ engine can run its controller/judge prompts as that user. Pure + exported for unit
|
|
29
|
+
* testing; {@link MJProgressComputerUseEngine.Run} is a one-liner over it.
|
|
30
|
+
*
|
|
31
|
+
* @param params The base computer-use params the CDP session built (goal + step cap + start url).
|
|
32
|
+
* @param contextUser The acting MJ user (may be undefined; the MJ engine handles the unset case).
|
|
33
|
+
* @param agentRunID Optional parent agent-run id (links the goal's prompt runs to the realtime agent run).
|
|
34
|
+
* @param agentRunStepID Optional parent agent-run-step id (nests a child prompt step per prompt under it).
|
|
35
|
+
* @returns The MJ-aware params to hand to `MJComputerUseEngine.Run`.
|
|
36
|
+
*/
|
|
37
|
+
export function buildMJGoalParams(params: RunComputerUseParams, contextUser?: UserInfo, agentRunID?: string, agentRunStepID?: string): MJRunComputerUseParams {
|
|
38
|
+
const mjParams = Object.assign(new MJRunComputerUseParams(), params);
|
|
39
|
+
mjParams.ContextUser = contextUser;
|
|
40
|
+
mjParams.AgentRunId = agentRunID;
|
|
41
|
+
mjParams.AgentRunStepID = agentRunStepID;
|
|
42
|
+
return mjParams;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The MJ-aware goal engine bound in production. Extends {@link MJComputerUseEngine} (prompt-runner routing,
|
|
47
|
+
* vision-model auto-selection, credential resolution, media persistence) and satisfies the
|
|
48
|
+
* {@link ComputerUseGoalRun} seam:
|
|
49
|
+
*
|
|
50
|
+
* - {@link SetBrowserAdapter} / {@link Stop} are inherited from the base computer-use engine.
|
|
51
|
+
* - {@link OnProgress} + {@link ContextUser} are set by the CDP session before {@link Run}.
|
|
52
|
+
* - {@link Run} adapts the transport-neutral base params into {@link MJRunComputerUseParams} (injecting the
|
|
53
|
+
* acting user) so the MJ engine can run its prompts.
|
|
54
|
+
* - {@link onStepComplete} chains the MJ media-persistence behavior, then forwards a model-safe progress note.
|
|
55
|
+
*/
|
|
56
|
+
export class MJProgressComputerUseEngine extends MJComputerUseEngine implements ComputerUseGoalRun {
|
|
57
|
+
/** Per-step progress hook the CDP session sets before {@link Run} (drives live narration). */
|
|
58
|
+
public OnProgress?: (progress: ComputerUseGoalProgress) => void;
|
|
59
|
+
|
|
60
|
+
/** The MJ user the goal run executes as; the CDP session sets it from the session's context user. */
|
|
61
|
+
public ContextUser?: UserInfo;
|
|
62
|
+
|
|
63
|
+
/** Parent agent-run id the CDP session sets before {@link Run} (links the goal's prompt runs to it). */
|
|
64
|
+
public AgentRunID?: string;
|
|
65
|
+
|
|
66
|
+
/** Parent agent-run-step id the CDP session sets before {@link Run} (nests child prompt steps under it). */
|
|
67
|
+
public AgentRunStepID?: string;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Adapts the transport-neutral base params into {@link MJRunComputerUseParams} (carrying the acting user
|
|
71
|
+
* + the parent run/step linkage) and delegates to the MJ engine, which auto-selects a vision-capable
|
|
72
|
+
* controller model from MJ metadata and nests a child step per prompt when the linkage is present.
|
|
73
|
+
*
|
|
74
|
+
* @param params The base computer-use params the CDP session built (goal + step cap + start url).
|
|
75
|
+
* @returns The computer-use run result.
|
|
76
|
+
*/
|
|
77
|
+
public override async Run(params: RunComputerUseParams): Promise<ComputerUseResult> {
|
|
78
|
+
return super.Run(buildMJGoalParams(params, this.ContextUser, this.AgentRunID, this.AgentRunStepID));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
protected override onStepComplete(step: StepRecord, params: MJRunComputerUseParams): void {
|
|
82
|
+
super.onStepComplete(step, params); // keep MJ media persistence
|
|
83
|
+
this.OnProgress?.(buildProgressNote(step)); // shared note shape with the base ProgressComputerUseEngine
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extracts the realtime co-agent observability run id from an `AIAgentSession.Config_` JSON blob (the
|
|
89
|
+
* `coAgentRunID` a voice session persists). Returns `undefined` for a missing/non-voice/un-parseable
|
|
90
|
+
* config — goal-run observability is best-effort and degrades to "no parent run" cleanly.
|
|
91
|
+
*
|
|
92
|
+
* @param configJson The session's `Config_` string.
|
|
93
|
+
* @returns The co-agent run id, or `undefined`.
|
|
94
|
+
*/
|
|
95
|
+
export function extractCoAgentRunID(configJson: string | null | undefined): string | undefined {
|
|
96
|
+
if (!configJson) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(configJson) as { coAgentRunID?: unknown };
|
|
101
|
+
return typeof parsed.coAgentRunID === 'string' ? parsed.coAgentRunID : undefined;
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Truncates a goal to a step-name-friendly length. */
|
|
108
|
+
function goalStepName(goal: string): string {
|
|
109
|
+
const trimmed = goal.trim();
|
|
110
|
+
return `Browser goal: ${trimmed.length > 120 ? `${trimmed.slice(0, 120)}…` : trimmed}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates the parent `Tool` step ("Browser goal: …") on the realtime co-agent run for a goal-driven browser
|
|
115
|
+
* run — a single top-level timeline node the computer-use prompt steps then nest under. Best-effort: returns
|
|
116
|
+
* `null` (so the goal still runs, just unlinked) when there's no co-agent run to attach to, or on any error.
|
|
117
|
+
* Uses the shared {@link initAgentRunStep} helper so the field shape matches every other agent-run step.
|
|
118
|
+
*
|
|
119
|
+
* @param provider The metadata provider.
|
|
120
|
+
* @param contextUser The acting user.
|
|
121
|
+
* @param coAgentRunID The realtime co-agent run id (the parent run), or `undefined`.
|
|
122
|
+
* @param goal The natural-language goal (becomes the step name).
|
|
123
|
+
* @returns The saved parent step (to finalize + nest under), or `null`.
|
|
124
|
+
*/
|
|
125
|
+
export async function beginBrowserGoalStep(
|
|
126
|
+
provider: IMetadataProvider,
|
|
127
|
+
contextUser: UserInfo | undefined,
|
|
128
|
+
coAgentRunID: string | undefined,
|
|
129
|
+
goal: string,
|
|
130
|
+
): Promise<MJAIAgentRunStepEntity | null> {
|
|
131
|
+
if (!coAgentRunID) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const rv = new RunView();
|
|
136
|
+
const countResult = await rv.RunView(
|
|
137
|
+
{ EntityName: 'MJ: AI Agent Run Steps', ExtraFilter: `AgentRunID='${coAgentRunID.replace(/'/g, "''")}'`, Fields: ['ID'], ResultType: 'simple' },
|
|
138
|
+
contextUser,
|
|
139
|
+
);
|
|
140
|
+
const stepNumber = (countResult.Success ? (countResult.Results?.length ?? 0) : 0) + 1;
|
|
141
|
+
const step = await provider.GetEntityObject<MJAIAgentRunStepEntity>('MJ: AI Agent Run Steps', contextUser);
|
|
142
|
+
step.NewRecord();
|
|
143
|
+
initAgentRunStep(step, { AgentRunID: coAgentRunID, StepNumber: stepNumber, StepType: 'Tool', StepName: goalStepName(goal) });
|
|
144
|
+
if (await step.Save()) {
|
|
145
|
+
return step;
|
|
146
|
+
}
|
|
147
|
+
LogError(`[RemoteBrowserGoalEngine] beginBrowserGoalStep save failed: ${step.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
148
|
+
return null;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
LogError(`[RemoteBrowserGoalEngine] beginBrowserGoalStep threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Finalizes the parent browser-goal step from the goal outcome — `Completed`/`Failed` with an `OutputData`
|
|
157
|
+
* envelope of strategy/status/step-count/url. A no-op when `step` is `null`. Best-effort (logged, never thrown).
|
|
158
|
+
*
|
|
159
|
+
* @param step The parent step from {@link beginBrowserGoalStep} (or `null`).
|
|
160
|
+
* @param result The goal outcome.
|
|
161
|
+
*/
|
|
162
|
+
export async function finalizeBrowserGoalStep(step: MJAIAgentRunStepEntity | null, result: RemoteBrowserGoalResult): Promise<void> {
|
|
163
|
+
if (!step) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
finalizeAgentRunStep(step, {
|
|
168
|
+
success: result.Success,
|
|
169
|
+
errorMessage: result.Success ? undefined : result.Detail,
|
|
170
|
+
outputData: { strategy: result.Strategy, status: result.Status, stepCount: result.StepCount, currentUrl: result.CurrentUrl },
|
|
171
|
+
});
|
|
172
|
+
if (!(await step.Save())) {
|
|
173
|
+
LogError(`[RemoteBrowserGoalEngine] finalizeBrowserGoalStep save failed: ${step.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
LogError(`[RemoteBrowserGoalEngine] finalizeBrowserGoalStep threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Binds the CDP goal-engine factory to {@link MJProgressComputerUseEngine}. Idempotent and safe to call at
|
|
182
|
+
* startup; failures are logged (never thrown) so a binding hiccup can't abort server boot — goal runs would
|
|
183
|
+
* simply fall back to the base engine.
|
|
184
|
+
*/
|
|
185
|
+
export function BindRemoteBrowserGoalEngine(): void {
|
|
186
|
+
try {
|
|
187
|
+
CdpRemoteBrowserSession.SetGoalEngineFactory(() => new MJProgressComputerUseEngine());
|
|
188
|
+
} catch (err) {
|
|
189
|
+
LogError(`[RemoteBrowserGoalEngine] Failed to bind MJ computer-use goal engine: ${err instanceof Error ? err.message : String(err)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|