diff --git a/command-snapshot.json b/command-snapshot.json index 2b7ac1d5..ea7d9577 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -150,7 +150,18 @@ "command": "org:open:agent", "flagAliases": ["urlonly"], "flagChars": ["b", "n", "o", "r"], - "flags": ["api-name", "api-version", "browser", "flags-dir", "json", "private", "target-org", "url-only"], + "flags": [ + "api-name", + "api-version", + "authoring-bundle", + "browser", + "flags-dir", + "json", + "private", + "target-org", + "url-only", + "version" + ], "plugin": "@salesforce/plugin-org" }, { diff --git a/messages/open.agent.md b/messages/open.agent.md index 78121b07..b5a911c5 100644 --- a/messages/open.agent.md +++ b/messages/open.agent.md @@ -6,6 +6,8 @@ Open an agent in your org's Agent Builder UI in a browser. Use the --api-name flag to open an agent using its API name in the Agent Builder UI of your org. To find the agent's API name, go to Setup in your org and navigate to the agent's details page. +Alternatively, use the --authoring-bundle flag to open an agent in Agentforce Builder. Optionally include --version to open a specific version of the agent. You'll specify the api name of the authoring bundle. + To generate the URL but not launch it in your browser, specify --url-only. To open Agent Builder in a specific browser, use the --browser flag. Supported browsers are "chrome", "edge", and "firefox". If you don't specify --browser, the org opens in your default browser. @@ -24,6 +26,14 @@ To open Agent Builder in a specific browser, use the --browser flag. Supported b $ <%= config.bin %> <%= command.id %> --target-org MyTestOrg1 --browser firefox --api-name Coral_Cloud_Agent +- Open an agent in Agentforce Builder using its authoring bundle name: + + $ <%= config.bin %> <%= command.id %> --authoring-bundle MyAgent + +- Open a specific version of an agent in Agentforce Builder: + + $ <%= config.bin %> <%= command.id %> --authoring-bundle MyAgent --version 1 + # flags.api-name.summary API name, also known as developer name, of the agent you want to open in the org's Agent Builder UI. @@ -39,3 +49,21 @@ Browser where the org opens. # flags.url-only.summary Display navigation URL, but don’t launch browser. + +# flags.authoring-bundle.summary + +API name of the agent to open in Agentforce Builder. + +# flags.authoring-bundle.description + +The API name of the agent to open directly in Agentforce Builder. Optionally specify --version to open a specific +version. + +# flags.version.summary + +Version number of the agent to open in Agentforce Builder. + +# flags.version.description + +The version number of the agent to open directly in Agentforce Builder. Can only be used with the --authoring-bundle +flag. diff --git a/src/commands/org/open/agent.ts b/src/commands/org/open/agent.ts index 0dc797ad..34f0268b 100644 --- a/src/commands/org/open/agent.ts +++ b/src/commands/org/open/agent.ts @@ -15,7 +15,7 @@ */ import { Flags } from '@salesforce/sf-plugins-core'; -import { Connection, Messages } from '@salesforce/core'; +import { Connection, Messages, SfError } from '@salesforce/core'; import { OrgOpenCommandBase } from '../../../shared/orgOpenCommandBase.js'; import { type OrgOpenOutput } from '../../../shared/orgTypes.js'; @@ -34,7 +34,7 @@ export class OrgOpenAgent extends OrgOpenCommandBase { 'api-name': Flags.string({ char: 'n', summary: messages.getMessage('flags.api-name.summary'), - required: true, + exactlyOne: ['api-name', 'authoring-bundle'], }), private: Flags.boolean({ summary: messages.getMessage('flags.private.summary'), @@ -52,6 +52,15 @@ export class OrgOpenAgent extends OrgOpenCommandBase { aliases: ['urlonly'], deprecateAliases: true, }), + 'authoring-bundle': Flags.string({ + summary: messages.getMessage('flags.authoring-bundle.summary'), + description: messages.getMessage('flags.authoring-bundle.description'), + exactlyOne: ['api-name', 'authoring-bundle'], + }), + version: Flags.string({ + summary: messages.getMessage('flags.version.summary'), + description: messages.getMessage('flags.version.description'), + }), }; public async run(): Promise { @@ -59,15 +68,47 @@ export class OrgOpenAgent extends OrgOpenCommandBase { this.org = flags['target-org']; this.connection = this.org.getConnection(flags['api-version']); - const agentBuilderRedirect = await buildRetUrl(this.connection, flags['api-name']); + let path: string; + if (flags['api-name']) { + path = await buildRetUrl(this.connection, flags['api-name'], flags.version); + } else { + // authoring-bundle is provided + const queryParams = new URLSearchParams({ + // flags.authoring-bundle guaranteed by OCLIF definition + projectName: flags['authoring-bundle']!, + }); + if (flags.version) { + queryParams.set('projectVersionNumber', flags.version); + } + path = `AgentAuthoring/agentAuthoringBuilder.app#/project?${queryParams.toString()}`; + } - return this.openOrgUI(flags, await this.org.getFrontDoorUrl(agentBuilderRedirect)); + return this.openOrgUI(flags, await this.org.getFrontDoorUrl(path)); } } // Build the URL part to the Agent Builder given a Bot API name. -const buildRetUrl = async (conn: Connection, botName: string): Promise => { +const buildRetUrl = async (conn: Connection, botName: string, version?: string): Promise => { const query = `SELECT id FROM BotDefinition WHERE DeveloperName='${botName}'`; - const botId = (await conn.singleRecordQuery<{ Id: string }>(query)).Id; - return `AiCopilot/copilotStudio.app#/copilot/builder?copilotId=${botId}`; + let botId: string; + try { + botId = (await conn.singleRecordQuery<{ Id: string }>(query)).Id; + } catch (error) { + throw new SfError(`No agent found with API name '${botName}' in the target org.`, 'AgentNotFound'); + } + + const queryParams = new URLSearchParams({ copilotId: botId }); + if (version) { + const versionQuery = `SELECT Id FROM BotVersion WHERE BotDefinitionId='${botId}' AND VersionNumber=${version}`; + try { + const versionId = (await conn.singleRecordQuery<{ Id: string }>(versionQuery)).Id; + queryParams.set('versionId', versionId); + } catch (error) { + throw new SfError( + `No version '${version}' found for agent '${botName}' in the target org.`, + 'AgentVersionNotFound' + ); + } + } + return `AiCopilot/copilotStudio.app#/copilot/builder?${queryParams.toString()}`; }; diff --git a/src/commands/org/open/authoring-bundle.ts b/src/commands/org/open/authoring-bundle.ts index 0139dc14..39296dba 100644 --- a/src/commands/org/open/authoring-bundle.ts +++ b/src/commands/org/open/authoring-bundle.ts @@ -26,6 +26,8 @@ export class OrgOpenAuthoringBundle extends OrgOpenCommandBase { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'deprecated'; + public static readonly deprecationOptions = { to: 'org open agent --authoring-bundle' }; public static readonly flags = { ...OrgOpenCommandBase.flags, diff --git a/test/unit/org/open/agent.test.ts b/test/unit/org/open/agent.test.ts new file mode 100644 index 00000000..24cb51e8 --- /dev/null +++ b/test/unit/org/open/agent.test.ts @@ -0,0 +1,427 @@ +/* + * 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 { EventEmitter } from 'node:events'; +import { assert, expect } from 'chai'; +import { Connection, SfdcUrl } from '@salesforce/core'; +import { stubMethod } from '@salesforce/ts-sinon'; +import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; +import { stubSfCommandUx, stubSpinner, stubUx } from '@salesforce/sf-plugins-core'; +import { OrgOpenAgent } from '../../../../src/commands/org/open/agent.js'; +import { OrgOpenOutput } from '../../../../src/shared/orgTypes.js'; +import utils from '../../../../src/shared/orgOpenUtils.js'; + +describe('org:open:agent', () => { + const $$ = new TestContext(); + const testOrg = new MockTestOrgData(); + + const singleUseToken = (Math.random() + 1).toString(36).substring(2); + const expectedDefaultSingleUseUrl = `${testOrg.instanceUrl}/secur/frontdoor.jsp?otp=${singleUseToken}`; + const getExpectedUrlWithPath = (path: string) => `${expectedDefaultSingleUseUrl}&startURL=${path}`; + + const mockBotId = '0Xx1234567890ABCD'; + const mockBotName = 'TestAgent'; + const mockVersionId = '0X9DD0000000032s0AA'; + + let sfCommandUxStubs: ReturnType; + + const testJsonStructure = (response: OrgOpenOutput) => { + expect(response).to.have.property('url'); + expect(response).to.have.property('username').equal(testOrg.username); + expect(response).to.have.property('orgId').equal(testOrg.orgId); + }; + + const spies = new Map(); + + beforeEach(async () => { + sfCommandUxStubs = stubSfCommandUx($$.SANDBOX); + stubUx($$.SANDBOX); + stubSpinner($$.SANDBOX); + await $$.stubAuths(testOrg); + spies.set('open', stubMethod($$.SANDBOX, utils, 'openUrl').resolves(new EventEmitter())); + spies.set( + 'requestGet', + stubMethod($$.SANDBOX, Connection.prototype, 'requestGet').callsFake((url: string) => { + const urlObj = new URL(url); + const redirectUri = urlObj.searchParams.get('redirect_uri'); + return Promise.resolve({ + // eslint-disable-next-line camelcase + frontdoor_uri: redirectUri + ? `${expectedDefaultSingleUseUrl}&startURL=${redirectUri}` + : expectedDefaultSingleUseUrl, + }); + }) + ); + spies.set('resolver', stubMethod($$.SANDBOX, SfdcUrl.prototype, 'checkLightningDomain').resolves('1.1.1.1')); + spies.set( + 'singleRecordQuery', + stubMethod($$.SANDBOX, Connection.prototype, 'singleRecordQuery').resolves({ Id: mockBotId }) + ); + }); + + afterEach(() => { + spies.clear(); + }); + + describe('flag validation', () => { + it('requires either api-name or authoring-bundle', async () => { + try { + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--url-only']); + assert.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect((error as Error).message).to.include('Exactly one of the following must be provided'); + expect((error as Error).message).to.include('--api-name'); + expect((error as Error).message).to.include('--authoring-bundle'); + } + }); + + it('does not allow both api-name and authoring-bundle', async () => { + try { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-name', + mockBotName, + '--authoring-bundle', + 'MyAgent', + ]); + assert.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect((error as Error).message).to.include('--api-name'); + expect((error as Error).message).to.include('--authoring-bundle'); + expect((error as Error).message).to.match(/exactly one|cannot also be provided/i); + } + }); + }); + + describe('url generation with api-name', () => { + it('builds URL with api-name using BotDefinition query', async () => { + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-name', + mockBotName, + ]); + assert(response); + testJsonStructure(response); + + // Verify the BotDefinition query was made + expect(spies.get('singleRecordQuery').callCount).to.equal(1); + expect(spies.get('singleRecordQuery').firstCall.args[0]).to.include(mockBotName); + expect(spies.get('singleRecordQuery').firstCall.args[0]).to.include('BotDefinition'); + + const expectedPath = `AiCopilot/copilotStudio.app#/copilot/builder?copilotId=${mockBotId}`; + expect(response.url).to.equal(getExpectedUrlWithPath(expectedPath)); + }); + + it('generates single-use URL when --url-only is not passed', async () => { + const response = await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--api-name', mockBotName]); + assert(response); + testJsonStructure(response); + expect(spies.get('requestGet').callCount).to.equal(1); + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('singleRecordQuery').callCount).to.equal(1); + }); + + it('properly queries BotDefinition with special characters in api-name', async () => { + const specialName = 'Test_Agent_01'; + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--url-only', '--api-name', specialName]); + + expect(spies.get('singleRecordQuery').callCount).to.equal(1); + expect(spies.get('singleRecordQuery').firstCall.args[0]).to.include(specialName); + }); + + it('builds URL with api-name and version using BotVersion query', async () => { + const version = '2'; + // Override the singleRecordQuery stub to return different results for BotDefinition and BotVersion + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const singleRecordQueryStub = spies.get('singleRecordQuery'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + singleRecordQueryStub.onFirstCall().resolves({ Id: mockBotId }); // BotDefinition query + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + singleRecordQueryStub.onSecondCall().resolves({ Id: mockVersionId }); // BotVersion query + + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-name', + mockBotName, + '--version', + version, + ]); + assert(response); + testJsonStructure(response); + + // Verify both queries were made + expect(singleRecordQueryStub.callCount).to.equal(2); + + // Verify BotDefinition query + expect(singleRecordQueryStub.firstCall.args[0]).to.include(mockBotName); + expect(singleRecordQueryStub.firstCall.args[0]).to.include('BotDefinition'); + + // Verify BotVersion query + expect(singleRecordQueryStub.secondCall.args[0]).to.include('BotVersion'); + expect(singleRecordQueryStub.secondCall.args[0]).to.include(mockBotId); + expect(singleRecordQueryStub.secondCall.args[0]).to.include(`VersionNumber=${version}`); + + const expectedPath = `AiCopilot/copilotStudio.app#/copilot/builder?copilotId=${mockBotId}&versionId=${mockVersionId}`; + expect(response.url).to.equal(getExpectedUrlWithPath(expectedPath)); + }); + }); + + describe('url generation with authoring-bundle', () => { + it('builds URL with authoring-bundle only', async () => { + const bundleName = 'MyTestAgent'; + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--authoring-bundle', + bundleName, + ]); + assert(response); + testJsonStructure(response); + + // Verify no BotDefinition query was made + expect(spies.get('singleRecordQuery').callCount).to.equal(0); + + const expectedPath = `AgentAuthoring/agentAuthoringBuilder.app#/project?projectName=${bundleName}`; + expect(response.url).to.equal(getExpectedUrlWithPath(expectedPath)); + }); + + it('builds URL with authoring-bundle and version', async () => { + const bundleName = 'MyTestAgent'; + const version = '13'; + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--authoring-bundle', + bundleName, + '--version', + version, + ]); + assert(response); + testJsonStructure(response); + + const expectedPath = `AgentAuthoring/agentAuthoringBuilder.app#/project?projectName=${bundleName}&projectVersionNumber=${version}`; + expect(response.url).to.equal(getExpectedUrlWithPath(expectedPath)); + }); + + it('generates single-use URL when --url-only is not passed', async () => { + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--authoring-bundle', + 'MyAgent', + ]); + assert(response); + testJsonStructure(response); + expect(spies.get('requestGet').callCount).to.equal(1); + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('singleRecordQuery').callCount).to.equal(0); + }); + }); + + describe('browser integration', () => { + it('opens in specified browser with api-name', async () => { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--api-name', + mockBotName, + '--browser', + 'firefox', + ]); + + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('open').args[0][1]).to.not.eql({}); + }); + + it('opens in specified browser with authoring-bundle', async () => { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--authoring-bundle', + 'MyAgent', + '--browser', + 'firefox', + ]); + + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('open').args[0][1]).to.not.eql({}); + }); + + it('opens in private mode with api-name', async () => { + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--api-name', mockBotName, '--private']); + + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('open').args[0][1]).to.have.property('newInstance'); + }); + + it('opens in private mode with authoring-bundle', async () => { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--authoring-bundle', + 'MyAgent', + '--private', + ]); + + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('open').args[0][1]).to.have.property('newInstance'); + }); + }); + + describe('human output', () => { + it('outputs success message without URL when opening browser with api-name', async () => { + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--api-name', mockBotName]); + + expect(sfCommandUxStubs.logSuccess.callCount).to.be.greaterThan(0); + expect(spies.get('open').callCount).to.equal(1); + }); + + it('outputs success message without URL when opening browser with authoring-bundle', async () => { + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--authoring-bundle', 'MyAgent']); + + expect(sfCommandUxStubs.logSuccess.callCount).to.be.greaterThan(0); + expect(spies.get('open').callCount).to.equal(1); + }); + + it('outputs URL when using --url-only with api-name', async () => { + await OrgOpenAgent.run(['--target-org', testOrg.username, '--url-only', '--api-name', mockBotName]); + + expect(sfCommandUxStubs.logSuccess.callCount).to.be.greaterThan(0); + expect(spies.get('open').callCount).to.equal(0); + }); + + it('outputs URL when using --url-only with authoring-bundle', async () => { + await OrgOpenAgent.run([ + '--target-org', + testOrg.username, + '--url-only', + '--authoring-bundle', + 'MyAgent', + '--version', + '1', + ]); + + expect(sfCommandUxStubs.logSuccess.callCount).to.be.greaterThan(0); + expect(spies.get('open').callCount).to.equal(0); + }); + }); + + describe('error handling', () => { + it('throws helpful error when agent API name does not exist', async () => { + const nonExistentAgent = 'NonExistent_Agent'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + spies.get('singleRecordQuery').rejects(new Error('No records found')); + + try { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-name', + nonExistentAgent, + ]); + assert.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect((error as Error).message).to.include(`No agent found with API name '${nonExistentAgent}'`); + expect((error as Error).name).to.equal('AgentNotFound'); + } + }); + + it('throws helpful error when agent version does not exist', async () => { + const nonExistentVersion = '999'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const singleRecordQueryStub = spies.get('singleRecordQuery'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + singleRecordQueryStub.onFirstCall().resolves({ Id: mockBotId }); // BotDefinition succeeds + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + singleRecordQueryStub.onSecondCall().rejects(new Error('No records found')); // BotVersion fails + + try { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-name', + mockBotName, + '--version', + nonExistentVersion, + ]); + assert.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect((error as Error).message).to.include( + `No version '${nonExistentVersion}' found for agent '${mockBotName}'` + ); + expect((error as Error).name).to.equal('AgentVersionNotFound'); + } + }); + }); + + describe('api-version flag', () => { + it('respects api-version flag with api-name', async () => { + const apiVersion = '59.0'; + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-version', + apiVersion, + '--api-name', + mockBotName, + ]); + assert(response); + testJsonStructure(response); + }); + + it('respects api-version flag with authoring-bundle', async () => { + const apiVersion = '59.0'; + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-version', + apiVersion, + '--authoring-bundle', + 'MyAgent', + ]); + assert(response); + testJsonStructure(response); + }); + }); +});