diff --git a/command-snapshot.json b/command-snapshot.json index 050e1343..d7eb3fec 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -92,6 +92,7 @@ "flagAliases": [], "flagChars": ["d", "n", "o", "x"], "flags": [ + "agent-json", "apex-debug", "api-name", "api-version", @@ -142,6 +143,7 @@ "flagAliases": [], "flagChars": ["n", "o"], "flags": [ + "agent-json", "api-name", "api-version", "authoring-bundle", diff --git a/messages/agent.preview.md b/messages/agent.preview.md index d4d38ea8..59c0406b 100644 --- a/messages/agent.preview.md +++ b/messages/agent.preview.md @@ -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". diff --git a/messages/agent.preview.start.md b/messages/agent.preview.start.md index e6a04de3..fd8356dc 100644 --- a/messages/agent.preview.start.md +++ b/messages/agent.preview.start.md @@ -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. diff --git a/src/commands/agent/preview.ts b/src/commands/agent/preview.ts index d46faa36..1938fd13 100644 --- a/src/commands/agent/preview.ts +++ b/src/commands/agent/preview.ts @@ -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'; @@ -69,6 +70,12 @@ export default class AgentPreview extends SfCommand { 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 { @@ -81,11 +88,24 @@ export default class AgentPreview extends SfCommand { 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 }); diff --git a/src/commands/agent/preview/start.ts b/src/commands/agent/preview/start.ts index 61a2e2dc..5b6b457b 100644 --- a/src/commands/agent/preview/start.ts +++ b/src/commands/agent/preview/start.ts @@ -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'; @@ -68,6 +69,12 @@ export default class AgentPreviewStart extends SfCommand { @@ -87,11 +94,18 @@ export default class AgentPreviewStart extends SfCommand { + 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' + ); + } +} diff --git a/test/commands/agent/preview/start.test.ts b/test/commands/agent/preview/start.test.ts index 6cee2026..d9c0276c 100644 --- a/test/commands/agent/preview/start.test.ts +++ b/test/commands/agent/preview/start.test.ts @@ -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'; @@ -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([ diff --git a/test/nuts/z3.agent.preview.nut.ts b/test/nuts/z3.agent.preview.nut.ts index cbaf7724..5454dd4d 100644 --- a/test/nuts/z3.agent.preview.nut.ts +++ b/test/nuts/z3.agent.preview.nut.ts @@ -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'; @@ -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( + `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(