Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"flagAliases": [],
"flagChars": ["d", "n", "o", "x"],
"flags": [
"agent-json",
"apex-debug",
"api-name",
"api-version",
Expand Down Expand Up @@ -142,6 +143,7 @@
"flagAliases": [],
"flagChars": ["n", "o"],
"flags": [
"agent-json",
"api-name",
"api-version",
"authoring-bundle",
Expand Down
4 changes: 4 additions & 0 deletions messages/agent.preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ Use real actions in the org; if not specified, preview uses AI to simulate (mock

Enable Apex debug logging during the agent preview conversation.

# flags.agent-json.summary

Path to a pre-compiled AgentJSON file to use instead of compiling the Agent Script file. Intended for internal use and testing.

# examples

- Preview an agent by choosing from the list of available local Agent Script or published agents. If previewing a local Agent Script agent, use simulated mode. Use the org with alias "my-dev-org".
Expand Down
4 changes: 4 additions & 0 deletions messages/agent.preview.start.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ API name of the activated published agent you want to preview.

API name of the authoring bundle metadata component that contains the agent's Agent Script file.

# flags.agent-json.summary

Path to a pre-compiled AgentJSON file to use instead of compiling the Agent Script file. Intended for internal use and testing.

# flags.use-live-actions.summary

Execute real actions in the org (Apex classes, flows, etc.). Required with --authoring-bundle.
Expand Down
24 changes: 22 additions & 2 deletions src/commands/agent/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
* limitations under the License.
*/

import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
import React from 'react';
import { render } from 'ink';
import { Agent, AgentSource, PreviewableAgent, ProductionAgent, ScriptAgent } from '@salesforce/agents';
import { Agent, AgentSource, PreviewableAgent, ProductionAgent, ScriptAgent, type AgentJson } from '@salesforce/agents';
import { select } from '@inquirer/prompts';
import { Lifecycle, Messages, SfError } from '@salesforce/core';
import { AgentPreviewReact } from '../../components/agent-preview-react.js';
Expand Down Expand Up @@ -69,6 +70,12 @@ export default class AgentPreview extends SfCommand<AgentPreviewResult> {
summary: messages.getMessage('flags.use-live-actions.summary'),
default: false,
}),
'agent-json': Flags.file({
summary: messages.getMessage('flags.agent-json.summary'),
hidden: true,
exists: true,
dependsOn: ['authoring-bundle'],
}),
};

public async run(): Promise<AgentPreviewResult> {
Expand All @@ -81,11 +88,24 @@ export default class AgentPreview extends SfCommand<AgentPreviewResult> {
const { 'api-name': apiNameOrId, 'use-live-actions': useLiveActions, 'authoring-bundle': aabName } = flags;
const conn = flags['target-org'].getConnection(flags['api-version']);

let preloadedAgentJson: AgentJson | undefined;
if (flags['agent-json']) {
try {
preloadedAgentJson = JSON.parse(await readFile(flags['agent-json'], 'utf-8')) as AgentJson;
} catch (error) {
await Lifecycle.getInstance().emitTelemetry({ eventName: 'agent_preview_agent_json_read_failed' });
throw new SfError(
`Failed to read or parse --agent-json file '${flags['agent-json']}': ${SfError.wrap(error).message}`,
'AgentJsonReadError'
);
}
}

let selectedAgent: ScriptAgent | ProductionAgent;

if (aabName) {
// user specified --authoring-bundle, use the API name directly
selectedAgent = await Agent.init({ connection: conn, project: this.project!, aabName });
selectedAgent = await Agent.init({ connection: conn, project: this.project!, aabName, agentJson: preloadedAgentJson });
selectedAgent.preview.setMockMode(flags['use-live-actions'] ? 'Live Test' : 'Mock');
} else if (apiNameOrId) {
selectedAgent = await Agent.init({ connection: conn, project: this.project!, apiNameOrId });
Expand Down
31 changes: 29 additions & 2 deletions src/commands/agent/preview/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
* limitations under the License.
*/

import { readFile } from 'node:fs/promises';
import { Flags, SfCommand, toHelpSection } from '@salesforce/sf-plugins-core';
import { EnvironmentVariable, Lifecycle, Messages, SfError } from '@salesforce/core';
import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents';
import { Agent, ProductionAgent, ScriptAgent, type AgentJson } from '@salesforce/agents';
import { createCache, SessionType } from '../../../previewSessionStore.js';
import { COMPILATION_API_EXIT_CODES } from '../../../common.js';

Expand Down Expand Up @@ -68,6 +69,12 @@ export default class AgentPreviewStart extends SfCommand<AgentPreviewStartResult
summary: messages.getMessage('flags.simulate-actions.summary'),
exclusive: ['use-live-actions'],
}),
'agent-json': Flags.file({
summary: messages.getMessage('flags.agent-json.summary'),
hidden: true,
exists: true,
dependsOn: ['authoring-bundle'],
}),
};

public async run(): Promise<AgentPreviewStartResult> {
Expand All @@ -87,11 +94,18 @@ export default class AgentPreviewStart extends SfCommand<AgentPreviewStartResult
const simulateActions = flags['simulate-actions'];
const agentIdentifier = flags['authoring-bundle'] ?? flags['api-name']!;

const preloadedAgentJson = await loadAgentJson(flags['agent-json']);

// Track telemetry for agent initialization
let agent: ScriptAgent | ProductionAgent;
try {
agent = flags['authoring-bundle']
? await Agent.init({ connection: conn, project: this.project!, aabName: flags['authoring-bundle'] })
? await Agent.init({
connection: conn,
project: this.project!,
aabName: flags['authoring-bundle'],
agentJson: preloadedAgentJson,
})
: await Agent.init({ connection: conn, project: this.project!, apiNameOrId: flags['api-name']! });
} catch (error) {
const wrapped = SfError.wrap(error);
Expand Down Expand Up @@ -172,3 +186,16 @@ function resolveSessionType(agent: ScriptAgent | ProductionAgent, simulateAction
if (agent instanceof ProductionAgent) return 'published';
return simulateActions ? 'simulated' : 'live';
}

async function loadAgentJson(filePath: string | undefined): Promise<AgentJson | undefined> {
if (!filePath) return undefined;
try {
return JSON.parse(await readFile(filePath, 'utf-8')) as AgentJson;
} catch (error) {
await Lifecycle.getInstance().emitTelemetry({ eventName: 'agent_preview_start_agent_json_read_failed' });
throw new SfError(
`Failed to read or parse --agent-json file '${filePath}': ${SfError.wrap(error).message}`,
'AgentJsonReadError'
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add telemetry logging similar to line 120?

}
}
105 changes: 105 additions & 0 deletions test/commands/agent/preview/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */

import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect } from 'chai';
import sinon from 'sinon';
Expand Down Expand Up @@ -76,6 +78,109 @@ describe('agent preview start', () => {
$$.restore();
});

describe('--agent-json flag', () => {
const mockAgentJson = {
schemaVersion: '1.0',
globalConfiguration: {
developerName: 'TestAgent',
label: 'Test Agent',
description: '',
enableEnhancedEventLogs: false,
agentType: 'AgentforceServiceAgent',
templateName: '',
defaultAgentUser: 'test@user.com',
defaultOutboundRouting: '',
contextVariables: [],
},
agentVersion: {
developerName: null,
plannerType: 'ReAct',
systemMessages: [],
modalityParameters: { voice: {}, language: {} },
additionalParameters: false,
company: 'Test',
role: 'Assistant',
stateVariables: [],
initialNode: 'start',
nodes: [],
knowledgeDefinitions: null,
},
};
let agentJsonInitStub: sinon.SinonStub;
let AgentPreviewStartWithFileFlag: any;
let tmpDir: string;

beforeEach(async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'agent-json-test-'));

const mockPreview = {
setMockMode: $$.SANDBOX.stub(),
start: $$.SANDBOX.stub().resolves({ sessionId: 'test-session-id' }),
};
class MockScriptAgent {
public preview = mockPreview;
public name = 'TestAgent';
}
agentJsonInitStub = $$.SANDBOX.stub().resolves(new MockScriptAgent());

const mod = await esmock('../../../../src/commands/agent/preview/start.js', {
'@salesforce/agents': {
Agent: { init: agentJsonInitStub },
ScriptAgent: MockScriptAgent,
ProductionAgent: class ProductionAgent {},
},
'../../../../src/previewSessionStore.js': {
createCache: $$.SANDBOX.stub().resolves(),
},
});
AgentPreviewStartWithFileFlag = mod.default;
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

it('reads the file and passes parsed agentJson to Agent.init', async () => {
const agentJsonPath = join(tmpDir, 'agent.json');
writeFileSync(agentJsonPath, JSON.stringify(mockAgentJson));

await AgentPreviewStartWithFileFlag.run([
'--authoring-bundle',
'MyAgent',
'--simulate-actions',
'--target-org',
'test@org.com',
'--agent-json',
agentJsonPath,
]);

expect(agentJsonInitStub.calledOnce).to.be.true;
const initArg = agentJsonInitStub.firstCall.args[0];
expect(initArg).to.have.property('agentJson');
expect(initArg.agentJson).to.deep.equal(mockAgentJson);
});

it('throws AgentJsonReadError when file contains invalid JSON', async () => {
const badJsonPath = join(tmpDir, 'bad.json');
writeFileSync(badJsonPath, 'not-valid-json{{{');

try {
await AgentPreviewStartWithFileFlag.run([
'--authoring-bundle',
'MyAgent',
'--simulate-actions',
'--target-org',
'test@org.com',
'--agent-json',
badJsonPath,
]);
expect.fail('Should have thrown');
} catch (error: unknown) {
expect((error as Error).message).to.include('Failed to read or parse --agent-json file');
}
});
});

describe('setMockMode', () => {
it('should call setMockMode with "Mock" when --simulate-actions is set', async () => {
await AgentPreviewStart.run([
Expand Down
75 changes: 75 additions & 0 deletions test/nuts/z3.agent.preview.nut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
* limitations under the License.
*/

import { writeFileSync, rmSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect } from 'chai';
import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit';
import { Agent } from '@salesforce/agents';
Expand All @@ -35,6 +39,77 @@ describe('agent preview', function () {
session = await getTestSession();
});

describe('--agent-json flag', () => {
let tmpDir: string;

before(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'agent-json-nut-'));
});

after(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

it('should start a preview session using a pre-compiled AgentJSON file', async function () {
this.timeout(5 * 60 * 1000);

const bundleApiName = 'Willie_Resort_Manager';
const targetOrg = getUsername();

// Compile agent JSON first via preview start (compile happens internally), then reuse it
// For the NUT we write a minimal valid agentJson extracted from a successful compile
const org = await Org.create({ aliasOrUsername: targetOrg });
const conn = org.getConnection();
const { ScriptAgent: ScriptAgentClass } = await import('@salesforce/agents');
const { SfProject } = await import('@salesforce/core');
const project = await SfProject.resolve(session.project.dir);
const scriptAgent = new ScriptAgentClass({ connection: conn, project, aabName: bundleApiName });
await scriptAgent.compile();

// @ts-expect-error accessing private field for NUT purposes
const agentJson = scriptAgent.agentJson as object;
expect(agentJson).to.not.be.undefined;

const agentJsonPath = join(tmpDir, 'compiled-agent.json');
writeFileSync(agentJsonPath, JSON.stringify(agentJson));

const startResult = execCmd<AgentPreviewStartResult>(
`agent preview start --authoring-bundle ${bundleApiName} --simulate-actions --agent-json ${agentJsonPath} --target-org ${targetOrg} --json`,
{ cwd: session.project.dir }
).jsonOutput?.result;

expect(startResult?.sessionId).to.be.a('string');
expect(startResult?.agentApiName).to.equal(bundleApiName);

// Clean up session
execCmd(
`agent preview end --session-id ${startResult!.sessionId} --authoring-bundle ${bundleApiName} --target-org ${targetOrg} --json`,
{ cwd: session.project.dir }
);
});

it('should fail when --agent-json contains invalid JSON', () => {
const badJsonPath = join(tmpDir, 'bad.json');
writeFileSync(badJsonPath, 'not-valid{{{');

const result = execCmd(
`agent preview start --authoring-bundle Willie_Resort_Manager --simulate-actions --agent-json ${badJsonPath} --target-org ${getUsername()} --json`,
{ ensureExitCode: 1, cwd: session.project.dir }
);
expect(JSON.stringify(result.shellOutput)).to.include('Failed to read or parse');
});

it('should fail when --agent-json is used without --authoring-bundle', () => {
const agentJsonPath = join(tmpDir, 'any.json');
writeFileSync(agentJsonPath, '{}');

execCmd(
`agent preview start --api-name Some_Agent --agent-json ${agentJsonPath} --target-org ${getUsername()} --json`,
{ ensureExitCode: 2 }
);
});
});

it('should fail when authoring bundle does not exist', async () => {
const invalidBundle = 'NonExistent_Bundle';
execCmd(
Expand Down
Loading