diff --git a/command-snapshot.json b/command-snapshot.json index 547b53b6..76eda4ce 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -4,7 +4,7 @@ "command": "agent:activate", "flagAliases": [], "flagChars": ["n", "o"], - "flags": ["api-name", "api-version", "flags-dir", "json", "target-org"], + "flags": ["api-name", "api-version", "flags-dir", "json", "target-org", "version"], "plugin": "@salesforce/plugin-agent" }, { diff --git a/messages/agent.activate.md b/messages/agent.activate.md index d370cff6..be4a68f7 100644 --- a/messages/agent.activate.md +++ b/messages/agent.activate.md @@ -22,6 +22,10 @@ You must know the agent's API name to activate it; you can either be prompted fo API name of the agent to activate. +# flags.version.summary + +Version number of the agent to activate. + # error.missingRequiredFlags Missing required flags: %s. diff --git a/schemas/agent-activate.json b/schemas/agent-activate.json new file mode 100644 index 00000000..5827721b --- /dev/null +++ b/schemas/agent-activate.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentActivateResult", + "definitions": { + "AgentActivateResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "version": { + "type": "number" + } + }, + "required": ["success", "version"], + "additionalProperties": false + } + } +} diff --git a/schemas/agent-deactivate.json b/schemas/agent-deactivate.json new file mode 100644 index 00000000..5827721b --- /dev/null +++ b/schemas/agent-deactivate.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentActivateResult", + "definitions": { + "AgentActivateResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "version": { + "type": "number" + } + }, + "required": ["success", "version"], + "additionalProperties": false + } + } +} diff --git a/src/agentActivation.ts b/src/agentActivation.ts index 07d9c110..f45d9d98 100644 --- a/src/agentActivation.ts +++ b/src/agentActivation.ts @@ -15,7 +15,7 @@ */ import { Messages, Org, SfError, SfProject } from '@salesforce/core'; -import { Agent, type BotMetadata, ProductionAgent } from '@salesforce/agents'; +import { Agent, type BotMetadata, type BotVersionMetadata, ProductionAgent } from '@salesforce/agents'; import { select } from '@inquirer/prompts'; type Choice = { @@ -28,6 +28,11 @@ type AgentValue = { DeveloperName: string; }; +type VersionChoice = { + version: number; + status: string; +}; + Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.activation'); @@ -48,8 +53,10 @@ export const getAgentChoices = (agents: BotMetadata[], status: 'Active' | 'Inact agents.map((agent) => { let disabled: string | boolean = false; - const lastBotVersion = agent.BotVersions.records[agent.BotVersions.records.length - 1]; - if (lastBotVersion.Status === status) { + // For deactivate (status='Inactive'), check if any version is Active (can be deactivated) + // For activate (status='Active'), check if any version is Inactive (can be activated) + const hasAvailableVersion = agent.BotVersions.records.some((version) => version.Status !== status); + if (!hasAvailableVersion) { disabled = `(Already ${status})`; } if (agentIsUnsupported(agent.DeveloperName)) { @@ -66,6 +73,22 @@ export const getAgentChoices = (agents: BotMetadata[], status: 'Active' | 'Inact }; }); +export const getVersionChoices = ( + versions: BotVersionMetadata[], + status: 'Active' | 'Inactive' +): Array> => + versions.map((version) => { + const isTargetStatus = version.Status === status; + return { + name: `Version ${version.VersionNumber}`, + value: { + version: version.VersionNumber, + status: version.Status, + }, + disabled: isTargetStatus ? `(Already ${status})` : false, + }; + }); + export const getAgentForActivation = async (config: { targetOrg: Org; status: 'Active' | 'Inactive'; @@ -110,3 +133,56 @@ export const getAgentForActivation = async (config: { project: SfProject.getInstance(), }); }; + +export const getVersionForActivation = async (config: { + agent: ProductionAgent; + status: 'Active' | 'Inactive'; + versionFlag?: number; + jsonEnabled?: boolean; +}): Promise<{ version: number | undefined; warning?: string }> => { + const { agent, status, versionFlag, jsonEnabled } = config; + + // If version flag is provided, return it + if (versionFlag !== undefined) { + return { version: versionFlag }; + } + + // Get bot metadata to access versions + const botMetadata = await agent.getBotMetadata(); + // Filter out deleted versions as a defensive measure + const versions = botMetadata.BotVersions.records.filter((v) => !v.IsDeleted); + + // If there's only one version, return it + if (versions.length === 1) { + return { version: versions[0].VersionNumber }; + } + + // Get version choices and filter out disabled ones + const choices = getVersionChoices(versions, status); + const availableChoices = choices.filter((choice) => !choice.disabled); + + // If there's only one available choice, return it automatically + if (availableChoices.length === 1) { + return { version: availableChoices[0].value.version }; + } + + // If JSON mode is enabled, automatically select the latest available version + if (jsonEnabled) { + // Find the latest (highest version number) available version + const latestVersion = availableChoices.reduce((latest, choice) => + choice.value.version > latest.value.version ? choice : latest + ); + return { + version: latestVersion.value.version, + warning: `No version specified, automatically selected latest available version: ${latestVersion.value.version}`, + }; + } + + // Prompt user to select a version + const versionChoice = await select({ + message: 'Select a version', + choices, + }); + + return { version: versionChoice.version }; +}; diff --git a/src/commands/agent/activate.ts b/src/commands/agent/activate.ts index 30d68706..3d1b7035 100644 --- a/src/commands/agent/activate.ts +++ b/src/commands/agent/activate.ts @@ -15,15 +15,18 @@ */ import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; -import { getAgentForActivation } from '../../agentActivation.js'; +import { getAgentForActivation, getVersionForActivation } from '../../agentActivation.js'; + +export type AgentActivateResult = { success: boolean; version: number }; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.activate'); -export default class AgentActivate extends SfCommand { +export default class AgentActivate extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); + public static readonly enableJsonFlag = true; public static readonly flags = { 'target-org': Flags.requiredOrg(), @@ -32,9 +35,10 @@ export default class AgentActivate extends SfCommand { summary: messages.getMessage('flags.api-name.summary'), char: 'n', }), + version: Flags.integer({ summary: messages.getMessage('flags.version.summary') }), }; - public async run(): Promise { + public async run(): Promise { const { flags } = await this.parse(AgentActivate); const apiNameFlag = flags['api-name']; @@ -43,11 +47,20 @@ export default class AgentActivate extends SfCommand { if (!apiNameFlag && this.jsonEnabled()) { throw messages.createError('error.missingRequiredFlags', ['api-name']); } - const agent = await getAgentForActivation({ targetOrg, status: 'Active', apiNameFlag }); - await agent.activate(); - const agentName = (await agent.getBotMetadata()).DeveloperName; + const { version, warning } = await getVersionForActivation({ + agent, + status: 'Active', + versionFlag: flags.version, + jsonEnabled: this.jsonEnabled(), + }); + const result = await agent.activate(version); + const metadata = await agent.getBotMetadata(); - this.log(`Agent ${agentName} activated.`); + this.log(`${metadata.DeveloperName} v${result.VersionNumber} activated.`); + if (warning) { + this.warn(warning); + } + return { success: true, version: result.VersionNumber }; } } diff --git a/src/commands/agent/deactivate.ts b/src/commands/agent/deactivate.ts index 733764ee..9d230f6a 100644 --- a/src/commands/agent/deactivate.ts +++ b/src/commands/agent/deactivate.ts @@ -16,11 +16,12 @@ import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; import { getAgentForActivation } from '../../agentActivation.js'; +import { AgentActivateResult } from './activate.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.deactivate'); -export default class AgentDeactivate extends SfCommand { +export default class AgentDeactivate extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); @@ -34,7 +35,7 @@ export default class AgentDeactivate extends SfCommand { }), }; - public async run(): Promise { + public async run(): Promise { const { flags } = await this.parse(AgentDeactivate); const apiNameFlag = flags['api-name']; @@ -45,9 +46,10 @@ export default class AgentDeactivate extends SfCommand { } const agent = await getAgentForActivation({ targetOrg, status: 'Inactive', apiNameFlag }); - await agent.deactivate(); - const agentName = (await agent.getBotMetadata()).DeveloperName; + const result = await agent.deactivate(); + const metadata = await agent.getBotMetadata(); - this.log(`Agent ${agentName} deactivated.`); + this.log(`${metadata.DeveloperName} v${result.VersionNumber} deactivated.`); + return { success: true, version: result.VersionNumber }; } } diff --git a/test/agentActivation.test.ts b/test/agentActivation.test.ts new file mode 100644 index 00000000..05b470cf --- /dev/null +++ b/test/agentActivation.test.ts @@ -0,0 +1,228 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { type BotMetadata, type BotVersionMetadata } from '@salesforce/agents'; +import { getAgentChoices, getVersionChoices } from '../src/agentActivation.js'; + +describe('agentActivation', () => { + describe('getVersionChoices', () => { + it('should mark versions with target status as disabled', () => { + const versions: BotVersionMetadata[] = [ + { + Id: 'v1', + Status: 'Active', + VersionNumber: 1, + DeveloperName: 'Test_v1', + } as BotVersionMetadata, + { + Id: 'v2', + Status: 'Inactive', + VersionNumber: 2, + DeveloperName: 'Test_v2', + } as BotVersionMetadata, + { + Id: 'v3', + Status: 'Inactive', + VersionNumber: 3, + DeveloperName: 'Test_v3', + } as BotVersionMetadata, + ]; + + const choices = getVersionChoices(versions, 'Inactive'); + + expect(choices).to.have.lengthOf(3); + expect(choices[0].disabled).to.equal(false); // Version 1 is Active, can be deactivated + expect(choices[1].disabled).to.equal('(Already Inactive)'); // Version 2 is already Inactive + expect(choices[2].disabled).to.equal('(Already Inactive)'); // Version 3 is already Inactive + }); + + it('should include version numbers in choices', () => { + const versions: BotVersionMetadata[] = [ + { + Id: 'v1', + Status: 'Active', + VersionNumber: 5, + DeveloperName: 'Test_v5', + } as BotVersionMetadata, + ]; + + const choices = getVersionChoices(versions, 'Active'); + + expect(choices[0].name).to.equal('Version 5'); + expect(choices[0].value.version).to.equal(5); + expect(choices[0].value.status).to.equal('Active'); + }); + + it('should mark active versions as available for deactivation', () => { + const versions: BotVersionMetadata[] = [ + { + Id: 'v1', + Status: 'Active', + VersionNumber: 1, + DeveloperName: 'Test_v1', + } as BotVersionMetadata, + { + Id: 'v2', + Status: 'Active', + VersionNumber: 2, + DeveloperName: 'Test_v2', + } as BotVersionMetadata, + ]; + + const choices = getVersionChoices(versions, 'Inactive'); + + expect(choices[0].disabled).to.equal(false); + expect(choices[1].disabled).to.equal(false); + }); + }); + + describe('getAgentChoices', () => { + it('should enable agent when ANY version is available for activation', () => { + const agents: BotMetadata[] = [ + { + Id: 'agent1', + DeveloperName: 'Test_Agent', + BotVersions: { + records: [ + { Status: 'Active', VersionNumber: 1 } as BotVersionMetadata, + { Status: 'Inactive', VersionNumber: 2 } as BotVersionMetadata, // Can be activated + { Status: 'Inactive', VersionNumber: 3 } as BotVersionMetadata, + ], + }, + } as BotMetadata, + ]; + + const choices = getAgentChoices(agents, 'Active'); + + expect(choices).to.have.lengthOf(1); + expect(choices[0].disabled).to.equal(false); // Has inactive versions that can be activated + expect(choices[0].value.DeveloperName).to.equal('Test_Agent'); + }); + + it('should enable agent when ANY version is available for deactivation', () => { + const agents: BotMetadata[] = [ + { + Id: 'agent1', + DeveloperName: 'Test_Agent', + BotVersions: { + records: [ + { Status: 'Inactive', VersionNumber: 1 } as BotVersionMetadata, + { Status: 'Active', VersionNumber: 2 } as BotVersionMetadata, // Can be deactivated + { Status: 'Inactive', VersionNumber: 3 } as BotVersionMetadata, + ], + }, + } as BotMetadata, + ]; + + const choices = getAgentChoices(agents, 'Inactive'); + + expect(choices).to.have.lengthOf(1); + expect(choices[0].disabled).to.equal(false); // Has active version that can be deactivated + }); + + it('should disable agent when ALL versions are already in target state', () => { + const agents: BotMetadata[] = [ + { + Id: 'agent1', + DeveloperName: 'Test_Agent', + BotVersions: { + records: [ + { Status: 'Active', VersionNumber: 1 } as BotVersionMetadata, + { Status: 'Active', VersionNumber: 2 } as BotVersionMetadata, + { Status: 'Active', VersionNumber: 3 } as BotVersionMetadata, + ], + }, + } as BotMetadata, + ]; + + const choices = getAgentChoices(agents, 'Active'); + + expect(choices).to.have.lengthOf(1); + expect(choices[0].disabled).to.equal('(Already Active)'); // All versions are already active + }); + + it('should disable agent when ALL versions are inactive for deactivation', () => { + const agents: BotMetadata[] = [ + { + Id: 'agent1', + DeveloperName: 'Test_Agent', + BotVersions: { + records: [ + { Status: 'Inactive', VersionNumber: 1 } as BotVersionMetadata, + { Status: 'Inactive', VersionNumber: 2 } as BotVersionMetadata, + ], + }, + } as BotMetadata, + ]; + + const choices = getAgentChoices(agents, 'Inactive'); + + expect(choices).to.have.lengthOf(1); + expect(choices[0].disabled).to.equal('(Already Inactive)'); // All versions are already inactive + }); + + it('should disable unsupported agents', () => { + const agents: BotMetadata[] = [ + { + Id: 'agent1', + DeveloperName: 'Copilot_for_Salesforce', + BotVersions: { + records: [{ Status: 'Inactive', VersionNumber: 1 } as BotVersionMetadata], + }, + } as BotMetadata, + ]; + + const choices = getAgentChoices(agents, 'Active'); + + expect(choices).to.have.lengthOf(1); + expect(choices[0].disabled).to.equal('(Not Supported)'); + }); + + it('should handle multiple agents with mixed states', () => { + const agents: BotMetadata[] = [ + { + Id: 'agent1', + DeveloperName: 'Agent_All_Active', + BotVersions: { + records: [ + { Status: 'Active', VersionNumber: 1 } as BotVersionMetadata, + { Status: 'Active', VersionNumber: 2 } as BotVersionMetadata, + ], + }, + } as BotMetadata, + { + Id: 'agent2', + DeveloperName: 'Agent_Mixed', + BotVersions: { + records: [ + { Status: 'Active', VersionNumber: 1 } as BotVersionMetadata, + { Status: 'Inactive', VersionNumber: 2 } as BotVersionMetadata, + ], + }, + } as BotMetadata, + ]; + + const choices = getAgentChoices(agents, 'Active'); + + expect(choices).to.have.lengthOf(2); + expect(choices[0].value.DeveloperName).to.equal('Agent_All_Active'); + expect(choices[0].disabled).to.equal('(Already Active)'); + expect(choices[1].value.DeveloperName).to.equal('Agent_Mixed'); + expect(choices[1].disabled).to.equal(false); + }); + }); +}); diff --git a/test/nuts/agent.activate.nut.ts b/test/nuts/agent.activate.nut.ts index 8ed91121..6d18a86d 100644 --- a/test/nuts/agent.activate.nut.ts +++ b/test/nuts/agent.activate.nut.ts @@ -85,6 +85,59 @@ describe('agent activate/deactivate NUTs', function () { expect(finalStatus).to.equal('Active'); }); + it('should activate the agent with version flag', async () => { + // Ensure agent is inactive first + const initialStatus = await getBotStatus(); + if (initialStatus === 'Active') { + execCmd(`agent deactivate --api-name ${botApiName} --target-org ${username} --json`, { + ensureExitCode: 0, + }); + await sleep(5000); + } + + // Activate with version 1 + execCmd(`agent activate --api-name ${botApiName} --target-org ${username} --version 1 --json`, { + ensureExitCode: 0, + }); + + // Verify the BotVersion status is now 'Active' + const finalStatus = await getBotStatus(); + expect(finalStatus).to.equal('Active'); + }); + + it('should auto-select latest version in JSON mode without version flag', async () => { + // Ensure agent is inactive first + const initialStatus = await getBotStatus(); + if (initialStatus === 'Active') { + execCmd(`agent deactivate --api-name ${botApiName} --target-org ${username} --json`, { + ensureExitCode: 0, + }); + await sleep(5000); + } + + // Activate with --json but no --version flag + const result = execCmd<{ version: number; success: boolean }>( + `agent activate --api-name ${botApiName} --target-org ${username} --json`, + { + ensureExitCode: 0, + } + ); + + // Parse the JSON result + const jsonResult = result.jsonOutput!.result; + expect(jsonResult?.success).to.equal(true); + expect(jsonResult?.version).to.be.a('number'); + + // Verify the warning was included in the output + expect(result.shellOutput.stderr).to.include( + 'No version specified, automatically selected latest available version' + ); + + // Verify the BotVersion status is now 'Active' + const finalStatus = await getBotStatus(); + expect(finalStatus).to.equal('Active'); + }); + it('should deactivate the agent', async () => { // Verify the BotVersion status has 'Active' initial state const initialStatus = await getBotStatus(); @@ -98,4 +151,24 @@ describe('agent activate/deactivate NUTs', function () { const finalStatus = await getBotStatus(); expect(finalStatus).to.equal('Inactive'); }); + + it('should deactivate the agent (version automatically detected)', async () => { + // Ensure agent is active first + const initialStatus = await getBotStatus(); + if (initialStatus === 'Inactive') { + execCmd(`agent activate --api-name ${botApiName} --target-org ${username} --version 1 --json`, { + ensureExitCode: 0, + }); + await sleep(5000); + } + + // Deactivate (version is automatically detected) + execCmd(`agent deactivate --api-name ${botApiName} --target-org ${username} --json`, { + ensureExitCode: 0, + }); + + // Verify the BotVersion status is now 'Inactive' + const finalStatus = await getBotStatus(); + expect(finalStatus).to.equal('Inactive'); + }); });