diff --git a/README.md b/README.md index a02c36b4..8e4e7e0b 100644 --- a/README.md +++ b/README.md @@ -626,13 +626,16 @@ End an existing programmatic agent preview session and get trace location. ``` USAGE - $ sf agent preview end -o [--json] [--flags-dir ] [--api-version ] [--session-id ] [-n - ] [--authoring-bundle ] + $ sf agent preview end [--json] [--flags-dir ] [--api-version ] [--session-id ] [-n ] + [--authoring-bundle ] [--all] [-p] [-o ] FLAGS -n, --api-name= API name of the activated published agent you want to preview. - -o, --target-org= (required) Username or alias of the target org. Not required if the `target-org` - configuration variable is already set. + -o, --target-org= Username or alias of the target org. Required with --api-name. Not required if the + `target-org` configuration variable is already set. + -p, --no-prompt Skip confirmation when using --all (no effect otherwise). + --all End every cached preview session for the agent given by --api-name or + --authoring-bundle. --api-version= Override the api version used for api requests made by this command --authoring-bundle= API name of the authoring bundle metadata component that contains the agent's Agent Script file. @@ -652,7 +655,7 @@ DESCRIPTION The original "agent preview start" command outputs a session ID which you then use with the --session-id flag of this command to end the session. You don't have to specify the --session-id flag if an agent has only one active preview session. You must also use either the --authoring-bundle or --api-name flag to specify the API name of the authoring - bundle or the published agent, respecitvely. To find either API name, navigate to your package directory in your DX + bundle or the published agent, respectively. To find either API name, navigate to your package directory in your DX project. The API name of an authoring bundle is the same as its directory name under the "aiAuthoringBundles" metadata directory. Similarly, the published agent's API name is the same as its directory name under the "Bots" metadata directory. @@ -671,6 +674,10 @@ EXAMPLES one active session. $ sf agent preview end --authoring-bundle My_Local_Agent + + End every cached preview session for that authoring bundle without a confirmation prompt: + + $ sf agent preview end --all --authoring-bundle My_Local_Agent --no-prompt ``` _See code: [src/commands/agent/preview/end.ts](https://github.com/salesforcecli/plugin-agent/blob/1.34.0/src/commands/agent/preview/end.ts)_ diff --git a/command-snapshot.json b/command-snapshot.json index 050e1343..4a81a72e 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -107,8 +107,18 @@ "alias": [], "command": "agent:preview:end", "flagAliases": [], - "flagChars": ["n", "o"], - "flags": ["api-name", "api-version", "authoring-bundle", "flags-dir", "json", "session-id", "target-org"], + "flagChars": ["n", "o", "p"], + "flags": [ + "all", + "api-name", + "api-version", + "authoring-bundle", + "flags-dir", + "json", + "no-prompt", + "session-id", + "target-org" + ], "plugin": "@salesforce/plugin-agent" }, { diff --git a/messages/agent.preview.end.md b/messages/agent.preview.end.md index fc75cfe6..6628229d 100644 --- a/messages/agent.preview.end.md +++ b/messages/agent.preview.end.md @@ -6,7 +6,9 @@ End an existing programmatic agent preview session and get trace location. You must have previously started a programmatic agent preview session with the "agent preview start" command to then use this command to end it. This command also displays the local directory where the session trace files are stored. -The original "agent preview start" command outputs a session ID which you then use with the --session-id flag of this command to end the session. You don't have to specify the --session-id flag if an agent has only one active preview session. You must also use either the --authoring-bundle or --api-name flag to specify the API name of the authoring bundle or the published agent, respecitvely. To find either API name, navigate to your package directory in your DX project. The API name of an authoring bundle is the same as its directory name under the "aiAuthoringBundles" metadata directory. Similarly, the published agent's API name is the same as its directory name under the "Bots" metadata directory. +The original "agent preview start" command outputs a session ID which you then use with the --session-id flag of this command to end the session. You don't have to specify the --session-id flag if an agent has only one active preview session. You must also use either the --authoring-bundle or --api-name flag to specify the API name of the authoring bundle or the published agent, respectively. To find either API name, navigate to your package directory in your DX project. The API name of an authoring bundle is the same as its directory name under the "aiAuthoringBundles" metadata directory. Similarly, the published agent's API name is the same as its directory name under the "Bots" metadata directory. + +Use the --all flag to end all active preview sessions at once. You can combine --all with --api-name or --authoring-bundle to end only sessions for a specific agent, or use --all on its own to end every session across all agents in the project. # flags.session-id.summary @@ -20,6 +22,14 @@ 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.all.summary + +End all active preview sessions. Combine with --api-name or --authoring-bundle to limit to a specific agent, or use with only --target-org to end sessions for all agents found in the local session cache. Requires --target-org. + +# flags.no-prompt.summary + +Don't prompt for confirmation before ending sessions. Has an effect only when used with --all. + # error.noSession No agent preview session found. Run "sf agent preview start" to start a new agent preview session. @@ -44,9 +54,25 @@ Failed to end preview session: %s Session traces: %s +# output.noSessionsFound + +No active preview sessions found. + +# output.endedAll + +Ended %s preview session(s). + +# prompt.confirmAll + +About to end %s preview session(s) for agent '%s'. Continue? + +# prompt.confirmAllAgents + +About to end %s preview session(s) across %s agent(s). Continue? + # examples -- End a preview session of a published agent by specifying its session ID and API name ; use the default org: +- End a preview session of a published agent by specifying its session ID and API name; use the default org: <%= config.bin %> <%= command.id %> --session-id --api-name My_Published_Agent @@ -57,3 +83,11 @@ Session traces: %s - End a preview session of an agent using its authoring bundle API name; you get an error if the agent has more than one active session. <%= config.bin %> <%= command.id %> --authoring-bundle My_Local_Agent + +- End all active preview sessions for a specific agent without prompting: + + <%= config.bin %> <%= command.id %> --all --authoring-bundle My_Local_Agent --target-org --no-prompt + +- End all active preview sessions across every agent in the local session cache for an org: + + <%= config.bin %> <%= command.id %> --all --target-org diff --git a/schemas/agent-preview-end.json b/schemas/agent-preview-end.json index 9ce8737d..5458bdd6 100644 --- a/schemas/agent-preview-end.json +++ b/schemas/agent-preview-end.json @@ -3,6 +3,26 @@ "$ref": "#/definitions/AgentPreviewEndResult", "definitions": { "AgentPreviewEndResult": { + "anyOf": [ + { + "type": "object", + "properties": { + "ended": { + "type": "array", + "items": { + "$ref": "#/definitions/EndedSession" + } + } + }, + "required": ["ended"], + "additionalProperties": false + }, + { + "$ref": "#/definitions/EndedSession" + } + ] + }, + "EndedSession": { "type": "object", "properties": { "sessionId": { diff --git a/src/commands/agent/preview/end.ts b/src/commands/agent/preview/end.ts index 4e801002..5034b812 100644 --- a/src/commands/agent/preview/end.ts +++ b/src/commands/agent/preview/end.ts @@ -14,19 +14,39 @@ * limitations under the License. */ -import { Flags, SfCommand, toHelpSection } from '@salesforce/sf-plugins-core'; -import { Messages, SfError, Lifecycle, EnvironmentVariable } from '@salesforce/core'; +import { Flags, SfCommand, toHelpSection, prompts } from '@salesforce/sf-plugins-core'; +import { Messages, SfError, EnvironmentVariable } from '@salesforce/core'; import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; -import { getCachedSessionIds, removeCache, validatePreviewSession } from '../../../previewSessionStore.js'; +import type { Connection } from '@salesforce/core'; +import type { Interfaces } from '@oclif/core'; +import { + getCachedSessionIds, + listCachedSessions, + removeCache, + validatePreviewSession, + type CachedPreviewSessionEntry, +} from '../../../previewSessionStore.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.end'); -export type AgentPreviewEndResult = { +async function callPreviewEnd(agent: ScriptAgent | ProductionAgent): Promise { + if (agent instanceof ScriptAgent) { + await agent.preview.end(); + } else if (agent instanceof ProductionAgent) { + await agent.preview.end('UserRequest'); + } +} + +export type EndedSession = { sessionId: string; tracesPath: string; }; +export type AgentPreviewEndResult = { ended: EndedSession[] } | EndedSession; + +type SessionTask = { sessionId: string }; + export default class AgentPreviewEnd extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -40,56 +60,72 @@ export default class AgentPreviewEnd extends SfCommand { public static readonly errorCodes = toHelpSection('ERROR CODES', { 'Succeeded (0)': 'Preview session ended successfully and traces saved.', + 'ExactlyOneRequired (2)': + 'Neither --api-name nor --authoring-bundle was provided (required when --all is not set).', 'NotFound (2)': 'Agent not found, or no preview session exists for this agent.', 'PreviewEndFailed (4)': 'Failed to end the preview session.', + 'PreviewEndPartialFailure (68)': 'With --all, one or more sessions failed to end while others succeeded.', 'SessionAmbiguous (5)': 'Multiple preview sessions found; specify --session-id to choose one.', }); public static readonly flags = { - 'target-org': Flags.requiredOrg(), + 'target-org': Flags.optionalOrg(), 'api-version': Flags.orgApiVersion(), 'session-id': Flags.string({ summary: messages.getMessage('flags.session-id.summary'), required: false, + exclusive: ['all'], }), 'api-name': Flags.string({ summary: messages.getMessage('flags.api-name.summary'), char: 'n', - exactlyOne: ['api-name', 'authoring-bundle'], + exclusive: ['authoring-bundle'], + dependsOn: ['target-org'], }), 'authoring-bundle': Flags.string({ summary: messages.getMessage('flags.authoring-bundle.summary'), - exactlyOne: ['api-name', 'authoring-bundle'], + exclusive: ['api-name'], + }), + all: Flags.boolean({ + summary: messages.getMessage('flags.all.summary'), + exclusive: ['session-id'], + dependsOn: ['target-org'], + }), + 'no-prompt': Flags.boolean({ + summary: messages.getMessage('flags.no-prompt.summary'), + char: 'p', }), }; public async run(): Promise { const { flags } = await this.parse(AgentPreviewEnd); - const conn = flags['target-org'].getConnection(flags['api-version']); - const agentIdentifier = flags['authoring-bundle'] ?? flags['api-name']!; - // Initialize agent with error tracking - let agent; - 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!, apiNameOrId: flags['api-name']! }); - } catch (error) { - const wrapped = SfError.wrap(error); - await Lifecycle.getInstance().emitTelemetry({ eventName: 'agent_preview_end_agent_not_found' }); - throw new SfError(messages.getMessage('error.agentNotFound', [agentIdentifier]), 'AgentNotFound', [], 2, wrapped); + const conn = flags['target-org']?.getConnection(flags['api-version']); + + if (flags['all']) { + // --all requires --target-org (enforced via dependsOn), so conn is always defined here. + return this.endAll(flags, conn); + } + + // Without --all, exactly one of --api-name or --authoring-bundle is required. + if (!flags['api-name'] && !flags['authoring-bundle']) { + throw new SfError( + 'Exactly one of the following must be provided: --api-name, --authoring-bundle', + 'ExactlyOneRequired', + [], + 2 + ); } - // Get or validate session ID + const agent = await this.initAgent(flags, conn); + let sessionId = flags['session-id']; if (sessionId === undefined) { const cached = await getCachedSessionIds(this.project!, agent); if (cached.length === 0) { - await Lifecycle.getInstance().emitTelemetry({ eventName: 'agent_preview_end_no_session' }); throw new SfError(messages.getMessage('error.noSession'), 'PreviewSessionNotFound', [], 2); } if (cached.length > 1) { - await Lifecycle.getInstance().emitTelemetry({ eventName: 'agent_preview_end_multiple_sessions' }); throw new SfError( messages.getMessage('error.multipleSessions', [cached.join(', ')]), 'PreviewSessionAmbiguous', @@ -102,12 +138,10 @@ export default class AgentPreviewEnd extends SfCommand { agent.setSessionId(sessionId); - // Validate session try { await validatePreviewSession(agent); } catch (error) { const wrapped = SfError.wrap(error); - await Lifecycle.getInstance().emitTelemetry({ eventName: 'agent_preview_end_session_invalid' }); throw new SfError( messages.getMessage('error.sessionInvalid', [sessionId]), 'PreviewSessionInvalid', @@ -120,16 +154,10 @@ export default class AgentPreviewEnd extends SfCommand { const tracesPath = await agent.getHistoryDir(); await removeCache(agent); - // End preview with error tracking try { - if (agent instanceof ScriptAgent) { - await agent.preview.end(); - } else if (agent instanceof ProductionAgent) { - await agent.preview.end('UserRequest'); - } + await callPreviewEnd(agent); } catch (error) { const wrapped = SfError.wrap(error); - await Lifecycle.getInstance().emitTelemetry({ eventName: 'agent_preview_end_failed' }); throw new SfError( messages.getMessage('error.endFailed', [wrapped.message]), 'PreviewEndFailed', @@ -139,9 +167,209 @@ export default class AgentPreviewEnd extends SfCommand { ); } - await Lifecycle.getInstance().emitTelemetry({ eventName: 'agent_preview_end_success' }); - const result = { sessionId, tracesPath }; + const result: EndedSession = { sessionId, tracesPath }; this.log(messages.getMessage('output.tracesPath', [tracesPath])); return result; } + + private async initAgent( + flags: Pick, + conn: Connection | undefined + ): Promise { + const agentIdentifier = flags['authoring-bundle'] ?? flags['api-name']!; + try { + // conn is always defined when --api-name is used (validated in run()); for --authoring-bundle + // ScriptAgent performs only local operations so it may not need a connection at runtime. + // We pass conn as-is and let the agents library throw if it actually requires a connection. + return flags['authoring-bundle'] + ? await Agent.init({ + connection: conn as Connection, + project: this.project!, + aabName: flags['authoring-bundle'], + }) + : await Agent.init({ connection: conn as Connection, project: this.project!, apiNameOrId: flags['api-name']! }); + } catch (error) { + const wrapped = SfError.wrap(error); + throw new SfError(messages.getMessage('error.agentNotFound', [agentIdentifier]), 'AgentNotFound', [], 2, wrapped); + } + } + + private async endAll( + flags: Pick, + conn: Connection | undefined + ): Promise<{ ended: EndedSession[] }> { + // conn is always defined because --all dependsOn --target-org; cast to Connection. + const connection = conn as Connection; + const hasAgentIdentifier = flags['api-name'] !== undefined || flags['authoring-bundle'] !== undefined; + + if (hasAgentIdentifier) { + return this.endAllForAgent(flags, connection); + } + return this.endAllAgents(connection, flags['no-prompt'] ?? false); + } + + /** + * Path 1: --all + --api-name or --authoring-bundle + * Ends all sessions for a single specified agent. This is the original behaviour. + */ + private async endAllForAgent( + flags: Pick, + conn: Connection + ): Promise<{ ended: EndedSession[] }> { + const agent = await this.initAgent(flags, conn); + const agentId = agent.getAgentIdForStorage(); + const sessionIds = await getCachedSessionIds(this.project!, agent); + const sessionsToEnd: SessionTask[] = sessionIds.map((sessionId) => ({ sessionId })); + + if (sessionsToEnd.length === 0) { + this.log(messages.getMessage('output.noSessionsFound')); + return { ended: [] }; + } + + if (!flags['no-prompt']) { + const confirmed = await prompts.confirm({ + message: messages.getMessage('prompt.confirmAll', [sessionsToEnd.length, agentId]), + }); + if (!confirmed) { + return { ended: [] }; + } + } + + const { ended, failed } = await endSessionsForAgent(agent, sessionsToEnd); + return this.finishEndAll(ended, failed); + } + + /** + * Path 2: --all alone (no agent identifier). + * Reads all agents from the local cache via listCachedSessions and ends every session. + * sessionType 'published' → ProductionAgent (server-side DELETE). 'simulated'/'live' → ScriptAgent (local only). + * session-meta.json is always present for entries returned by listCachedSessions, so sessionType is always defined. + */ + private async endAllAgents(conn: Connection, noPrompt: boolean): Promise<{ ended: EndedSession[] }> { + const entries: CachedPreviewSessionEntry[] = await listCachedSessions(this.project!); + + const totalSessions = entries.reduce((sum, e) => sum + e.sessions.length, 0); + + if (totalSessions === 0) { + this.log(messages.getMessage('output.noSessionsFound')); + return { ended: [] }; + } + + if (!noPrompt) { + const agentBreakdown = entries + .map((e) => { + const label = e.displayName ?? e.agentId; + const type = e.sessions[0]?.sessionType === 'published' ? 'bot' : 'bundle'; // 'bot'/'bundle' labels confirmed by PM — intentional deviation from raw sessionType value + return ` - ${label} (${type}): ${e.sessions.length} session(s)`; + }) + .join('\n'); + const confirmed = await prompts.confirm({ + message: `${messages.getMessage('prompt.confirmAllAgents', [ + totalSessions, + entries.length, + ])}\n${agentBreakdown}`, + }); + if (!confirmed) { + return { ended: [] }; + } + } + + const ended: EndedSession[] = []; + const failed: Array<{ task: SessionTask; error: string }> = []; + + for (const entry of entries) { + const { agentId, sessions } = entry; + + let agent: ScriptAgent | ProductionAgent; + try { + const isProduction = sessions[0]?.sessionType === 'published'; + if (isProduction) { + // eslint-disable-next-line no-await-in-loop + agent = await Agent.init({ connection: conn, project: this.project!, apiNameOrId: agentId }); + } else { + // eslint-disable-next-line no-await-in-loop + agent = await Agent.init({ connection: conn, project: this.project!, aabName: agentId }); + } + } catch (error) { + // If we can't init the agent, mark all its sessions as failed. + const errMsg = SfError.wrap(error).message; + for (const s of sessions) { + failed.push({ task: { sessionId: s.sessionId }, error: errMsg }); + } + continue; + } + + // eslint-disable-next-line no-await-in-loop + const { ended: agentEnded, failed: agentFailed } = await endSessionsForAgent( + agent, + sessions.map((s) => ({ sessionId: s.sessionId })) + ); + ended.push(...agentEnded); + failed.push(...agentFailed); + } + + return this.finishEndAll(ended, failed); + } + + /** + * Called by endAllForAgent (single specified agent) and endAllAgents (all agents from cache) + * once ended/failed arrays have been fully aggregated. + * Throws a partial-failure error if needed; logs success otherwise. + */ + private finishEndAll( + ended: EndedSession[], + failed: Array<{ task: SessionTask; error: string }> + ): { ended: EndedSession[] } { + if (failed.length > 0) { + const failedList = failed.map((f) => `${f.task.sessionId}: ${f.error}`).join(', '); + const endedIds = ended.map((e) => e.sessionId).join(', '); + const msg = `Failed to end ${failed.length} session(s): [${failedList}]. Successfully ended ${ + ended.length + } session(s)${ended.length > 0 ? `: [${endedIds}]` : ''}.`; + throw new SfError(msg, 'PreviewEndPartialFailure', [], 68); + } + + this.log(messages.getMessage('output.endedAll', [ended.length])); + return { ended }; + } +} + +type CommandFlags = Interfaces.InferredFlags; + +/** + * Ends a list of sessions on the given agent object serially. + * Returns { ended, failed } so callers can aggregate results. + * Does NOT throw on partial failure — callers decide what to do. + * On failure, the local cache entry is NOT removed (consistent with single-session path behaviour). + */ +async function endSessionsForAgent( + agent: ScriptAgent | ProductionAgent, + sessionsToEnd: SessionTask[] +): Promise<{ ended: EndedSession[]; failed: Array<{ task: SessionTask; error: string }> }> { + const ended: EndedSession[] = []; + const failed: Array<{ task: SessionTask; error: string }> = []; + + for (const task of sessionsToEnd) { + const { sessionId } = task; + try { + agent.setSessionId(sessionId); + // eslint-disable-next-line no-await-in-loop + await validatePreviewSession(agent); + // eslint-disable-next-line no-await-in-loop + const tracesPath = await agent.getHistoryDir(); + // ScriptAgent flushes traces to disk; ProductionAgent issues the server-side end request. + // eslint-disable-next-line no-await-in-loop + await callPreviewEnd(agent); + // ProductionAgent.endSession() clears this.sessionId after the server call; re-set it so + // removeCache can call getHistoryDir() without throwing "No sessionId set on agent". + agent.setSessionId(sessionId); + // eslint-disable-next-line no-await-in-loop + await removeCache(agent); + ended.push({ sessionId, tracesPath }); + } catch (error) { + failed.push({ task, error: SfError.wrap(error).message }); + } + } + + return { ended, failed }; } diff --git a/test/commands/agent/preview/end.test.ts b/test/commands/agent/preview/end.test.ts new file mode 100644 index 00000000..02cc8bd2 --- /dev/null +++ b/test/commands/agent/preview/end.test.ts @@ -0,0 +1,541 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */ + +import { join } from 'node:path'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; +import { TestContext } from '@salesforce/core/testSetup'; +import { SfProject } from '@salesforce/core'; + +const MOCK_PROJECT_DIR = join(process.cwd(), 'test', 'mock-projects', 'agent-generate-template'); +const SESSION_ID = 'test-session-123'; +const AGENT_ID = 'my_agent_id'; +const TRACES_PATH = `/mock/.sfdx/agents/${AGENT_ID}/sessions/${SESSION_ID}`; + +describe('agent preview end', () => { + const $$ = new TestContext(); + let AgentPreviewEnd: any; + let initStub: sinon.SinonStub; + let getCachedSessionIdsStub: sinon.SinonStub; + let listCachedSessionsStub: sinon.SinonStub; + let removeCacheStub: sinon.SinonStub; + let validatePreviewSessionStub: sinon.SinonStub; + let confirmStub: sinon.SinonStub; + let agentPreviewEndStub: sinon.SinonStub; + + beforeEach(async () => { + agentPreviewEndStub = $$.SANDBOX.stub().resolves(); + getCachedSessionIdsStub = $$.SANDBOX.stub().resolves([SESSION_ID]); + listCachedSessionsStub = $$.SANDBOX.stub().resolves([]); + removeCacheStub = $$.SANDBOX.stub().resolves(); + validatePreviewSessionStub = $$.SANDBOX.stub().resolves(); + confirmStub = $$.SANDBOX.stub().resolves(true); + + const MockScriptAgent = class MockScriptAgent { + public preview = { end: agentPreviewEndStub }; + public name = 'TestAgent'; + public setSessionId = sinon.stub(); + public getHistoryDir = sinon.stub().resolves(TRACES_PATH); + public getAgentIdForStorage = sinon.stub().returns(AGENT_ID); + }; + const MockProductionAgent = class MockProductionAgent {}; + + const mockAgentInstance = new MockScriptAgent(); + initStub = $$.SANDBOX.stub().resolves(mockAgentInstance); + + const mod = await esmock('../../../../src/commands/agent/preview/end.js', { + '@salesforce/agents': { + Agent: { init: initStub }, + ScriptAgent: MockScriptAgent, + ProductionAgent: MockProductionAgent, + }, + '../../../../src/previewSessionStore.js': { + getCachedSessionIds: getCachedSessionIdsStub, + listCachedSessions: listCachedSessionsStub, + removeCache: removeCacheStub, + validatePreviewSession: validatePreviewSessionStub, + }, + '@salesforce/sf-plugins-core': { + Flags: (await import('@salesforce/sf-plugins-core')).Flags, + SfCommand: (await import('@salesforce/sf-plugins-core')).SfCommand, + toHelpSection: (await import('@salesforce/sf-plugins-core')).toHelpSection, + prompts: { + confirm: confirmStub, + }, + }, + }); + + AgentPreviewEnd = mod.default; + + $$.inProject(true); + const mockProject = { + getPath: () => MOCK_PROJECT_DIR, + getDefaultPackage: () => ({ fullPath: join(MOCK_PROJECT_DIR, 'force-app') }), + } as unknown as SfProject; + $$.SANDBOX.stub(SfProject, 'resolve').resolves(mockProject); + $$.SANDBOX.stub(SfProject, 'getInstance').returns(mockProject); + }); + + afterEach(() => { + $$.restore(); + }); + + describe('single-session end (default behaviour)', () => { + it('ends a session for an authoring bundle using the cached session ID', async () => { + const result = await AgentPreviewEnd.run(['--authoring-bundle', 'My_Local_Agent']); + + expect(initStub.calledOnce).to.be.true; + expect(validatePreviewSessionStub.calledOnce).to.be.true; + expect(removeCacheStub.calledOnce).to.be.true; + expect(agentPreviewEndStub.calledOnce).to.be.true; + expect(result).to.deep.include({ sessionId: SESSION_ID, tracesPath: TRACES_PATH }); + }); + + it('ends a session with an explicit --session-id flag, skipping the cache lookup', async () => { + const explicitSessionId = 'explicit-session-456'; + + const result = await AgentPreviewEnd.run([ + '--authoring-bundle', + 'My_Local_Agent', + '--session-id', + explicitSessionId, + ]); + + expect(getCachedSessionIdsStub.called).to.be.false; + expect(result).to.deep.include({ sessionId: explicitSessionId }); + }); + + it('throws when no session is cached for the agent', async () => { + getCachedSessionIdsStub.resolves([]); + + try { + await AgentPreviewEnd.run(['--authoring-bundle', 'My_Local_Agent']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('No agent preview session found'); + } + }); + + it('throws when multiple sessions are cached for the agent', async () => { + getCachedSessionIdsStub.resolves(['session-1', 'session-2']); + + try { + await AgentPreviewEnd.run(['--authoring-bundle', 'My_Local_Agent']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Multiple preview sessions found'); + } + }); + + it('throws when --api-name is provided without --target-org', async () => { + try { + await AgentPreviewEnd.run(['--api-name', 'My_Published_Agent']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('--target-org'); + } + }); + + it('throws when neither --api-name, --authoring-bundle, nor --all is provided', async () => { + try { + await AgentPreviewEnd.run([]); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match( + /exactly one of the following must be provided.*--api-name.*--authoring-bundle/is + ); + } + }); + + it('throws when both --api-name and --authoring-bundle are provided at the same time', async () => { + try { + await AgentPreviewEnd.run(['--api-name', 'My_Published_Agent', '--authoring-bundle', 'My_Local_Agent']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/--api-name.*cannot also be provided when using --authoring-bundle/i); + } + }); + + it('throws when --session-id and --all are both provided', async () => { + try { + await AgentPreviewEnd.run(['--authoring-bundle', 'My_Local_Agent', '--session-id', 'sid', '--all']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/cannot also be provided/i); + } + }); + }); + + describe('--all flag: ends all cached sessions for the specified agent', () => { + it('throws when --all is used without --target-org', async () => { + try { + await AgentPreviewEnd.run(['--all', '--authoring-bundle', 'My_Local_Agent']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('--target-org'); + } + }); + + it('filters to the specified agent when combined with --authoring-bundle', async () => { + getCachedSessionIdsStub.resolves(['session-1', 'session-2']); + + const result = await AgentPreviewEnd.run([ + '--all', + '--authoring-bundle', + 'My_Local_Agent', + '--target-org', + 'test@org.com', + '--no-prompt', + ]); + + expect(initStub.calledOnce).to.be.true; + expect(validatePreviewSessionStub.callCount).to.equal(2); + expect(removeCacheStub.callCount).to.equal(2); + expect((result as { ended: unknown[] }).ended).to.have.length(2); + }); + + it('filters to the specified agent when combined with --api-name and --target-org (happy path)', async () => { + getCachedSessionIdsStub.resolves(['session-a', 'session-b']); + + const result = await AgentPreviewEnd.run([ + '--all', + '--api-name', + 'My_Published_Agent', + '--target-org', + 'test@org.com', + '--no-prompt', + ]); + + expect(initStub.calledOnce).to.be.true; + expect(validatePreviewSessionStub.callCount).to.equal(2); + expect(removeCacheStub.callCount).to.equal(2); + expect((result as { ended: unknown[] }).ended).to.have.length(2); + }); + + it('throws when --all + --api-name is used without --target-org', async () => { + try { + await AgentPreviewEnd.run(['--all', '--api-name', 'My_Published_Agent']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('--target-org'); + } + }); + + it('logs a message and returns an empty list when no sessions are found', async () => { + getCachedSessionIdsStub.resolves([]); + + const result = await AgentPreviewEnd.run([ + '--all', + '--authoring-bundle', + 'My_Local_Agent', + '--target-org', + 'test@org.com', + '--no-prompt', + ]); + + expect(result).to.deep.equal({ ended: [] }); + expect(removeCacheStub.called).to.be.false; + }); + + it('records partial results and throws a structured error when agent.preview.end() throws mid-loop', async () => { + // Three sessions: session-1 succeeds, session-2 fails, session-3 succeeds + getCachedSessionIdsStub.resolves(['session-1', 'session-2', 'session-3']); + // Fail only on the second call (session-2) + agentPreviewEndStub + .onFirstCall() + .resolves() + .onSecondCall() + .rejects(new Error('network timeout')) + .onThirdCall() + .resolves(); + + try { + await AgentPreviewEnd.run([ + '--all', + '--authoring-bundle', + 'My_Local_Agent', + '--target-org', + 'test@org.com', + '--no-prompt', + ]); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + const err = error as any; + // Structured error: lists which sessions failed + expect(err.message).to.include('Failed to end 1 session(s)'); + expect(err.message).to.include('session-2'); + expect(err.message).to.include('network timeout'); + // Also mentions the ones that succeeded + expect(err.message).to.include('Successfully ended 2 session(s)'); + // 2 removes: session-1 (success), session-3 (success); session-2 fails so no cache removal + expect(removeCacheStub.callCount).to.equal(2); + expect(err.name).to.equal('PreviewEndPartialFailure'); + expect(err.exitCode).to.equal(68); + } + }); + + it('records partial results when validatePreviewSession fails for one session', async () => { + getCachedSessionIdsStub.resolves(['session-1', 'session-2']); + validatePreviewSessionStub.onFirstCall().resolves().onSecondCall().rejects(new Error('stale session')); + + try { + await AgentPreviewEnd.run([ + '--all', + '--authoring-bundle', + 'My_Local_Agent', + '--target-org', + 'test@org.com', + '--no-prompt', + ]); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + const err = error as any; + expect(err.message).to.include('session-2'); + expect(err.message).to.include('stale session'); + expect(err.message).to.include('Successfully ended 1 session(s)'); + // 1 remove: session-1 (success); session-2 validate fails so no cache removal + expect(removeCacheStub.callCount).to.equal(1); + expect(agentPreviewEndStub.callCount).to.equal(1); + expect(err.name).to.equal('PreviewEndPartialFailure'); + } + }); + }); + + describe('--all flag: confirmation prompt', () => { + it('prompts for confirmation before ending sessions', async () => { + getCachedSessionIdsStub.resolves([SESSION_ID]); + confirmStub.resolves(true); + + await AgentPreviewEnd.run(['--all', '--authoring-bundle', 'My_Local_Agent', '--target-org', 'test@org.com']); + + expect(confirmStub.calledOnce).to.be.true; + expect(removeCacheStub.calledOnce).to.be.true; + }); + + it('returns an empty ended list when user declines the confirmation prompt', async () => { + getCachedSessionIdsStub.resolves([SESSION_ID]); + confirmStub.resolves(false); + + const result = await AgentPreviewEnd.run([ + '--all', + '--authoring-bundle', + 'My_Local_Agent', + '--target-org', + 'test@org.com', + ]); + + expect(removeCacheStub.called).to.be.false; + expect(result).to.deep.equal({ ended: [] }); + }); + + it('skips the confirmation prompt when --no-prompt is provided', async () => { + getCachedSessionIdsStub.resolves([SESSION_ID]); + + await AgentPreviewEnd.run([ + '--all', + '--authoring-bundle', + 'My_Local_Agent', + '--target-org', + 'test@org.com', + '--no-prompt', + ]); + + expect(confirmStub.called).to.be.false; + expect(removeCacheStub.calledOnce).to.be.true; + }); + }); + + describe('--all flag: all-agents path (no agent identifier)', () => { + it('ends sessions for all agents from listCachedSessions when only --target-org is provided', async () => { + listCachedSessionsStub.resolves([ + { + agentId: 'My_Script_Agent', + sessions: [ + { sessionId: 'sess-1', sessionType: 'simulated' }, + { sessionId: 'sess-2', sessionType: 'live' }, + ], + }, + { + agentId: '0Xxg8000000NBNlCAO', + sessions: [{ sessionId: 'sess-3', sessionType: 'published' }], + }, + ]); + + const result = await AgentPreviewEnd.run(['--all', '--target-org', 'test@org.com', '--no-prompt']); + + expect(listCachedSessionsStub.calledOnce).to.be.true; + expect(initStub.callCount).to.equal(2); + expect(validatePreviewSessionStub.callCount).to.equal(3); + expect(removeCacheStub.callCount).to.equal(3); + expect((result as { ended: unknown[] }).ended).to.have.length(3); + }); + + it('returns empty ended list when listCachedSessions returns no sessions', async () => { + listCachedSessionsStub.resolves([]); + + const result = await AgentPreviewEnd.run(['--all', '--target-org', 'test@org.com', '--no-prompt']); + + expect(result).to.deep.equal({ ended: [] }); + expect(initStub.called).to.be.false; + }); + + it('throws when --all alone is used without --target-org', async () => { + try { + await AgentPreviewEnd.run(['--all']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('--target-org'); + } + }); + + it('uses aabName (ScriptAgent) for live sessionType', async () => { + listCachedSessionsStub.resolves([ + { + agentId: 'Local_Info_Agent', + sessions: [{ sessionId: 'aab-sess-1', sessionType: 'live' }], + }, + ]); + + const result = await AgentPreviewEnd.run(['--all', '--target-org', 'test@org.com', '--no-prompt']); + + expect((result as { ended: unknown[] }).ended).to.have.length(1); + expect(initStub.calledOnce).to.be.true; + expect(initStub.firstCall.args[0]).to.have.property('aabName', 'Local_Info_Agent'); + expect(removeCacheStub.calledOnce).to.be.true; + }); + + it('uses aabName for simulated/live sessions and apiNameOrId for published', async () => { + listCachedSessionsStub.resolves([ + { + agentId: 'My_Script_Agent', + sessions: [{ sessionId: 'sess-sim', sessionType: 'simulated' }], + }, + { + agentId: 'Weather_Agent', + sessions: [{ sessionId: 'sess-pub', sessionType: 'published' }], + }, + ]); + + await AgentPreviewEnd.run(['--all', '--target-org', 'test@org.com', '--no-prompt']); + + expect(initStub.callCount).to.equal(2); + expect(initStub.firstCall.args[0]).to.have.property('aabName', 'My_Script_Agent'); + expect(initStub.secondCall.args[0]).to.have.property('apiNameOrId', 'Weather_Agent'); + }); + + it('throws PreviewEndPartialFailure when one agent succeeds and another throws (no agent identifier)', async () => { + // Two agents, one session each. The second agent's callPreviewEnd call throws. + // agent.preview.end is agentPreviewEndStub (shared across mock instances via MockScriptAgent). + agentPreviewEndStub.onFirstCall().resolves().onSecondCall().rejects(new Error('agent B exploded')); + + listCachedSessionsStub.resolves([ + { + agentId: 'Agent_A', + sessions: [{ sessionId: 'sess-a-1', sessionType: 'simulated' }], + }, + { + agentId: 'Agent_B', + sessions: [{ sessionId: 'sess-b-1', sessionType: 'simulated' }], + }, + ]); + + try { + await AgentPreviewEnd.run(['--all', '--target-org', 'test@org.com', '--no-prompt']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + const err = error as any; + expect(err.name).to.equal('PreviewEndPartialFailure'); + expect(err.message).to.include('Failed to end 1 session(s)'); + expect(err.message).to.include('sess-b-1'); + expect(err.message).to.include('agent B exploded'); + expect(err.message).to.include('Successfully ended 1 session(s)'); + expect(err.message).to.include('sess-a-1'); + } + }); + + it('prompts for confirmation and ends sessions when user confirms (all-agents path)', async () => { + listCachedSessionsStub.resolves([ + { + agentId: 'Confirmed_Agent', + sessions: [{ sessionId: 'conf-sess-1', sessionType: 'simulated' }], + }, + ]); + confirmStub.resolves(true); + + const result = await AgentPreviewEnd.run(['--all', '--target-org', 'test@org.com']); + + expect(confirmStub.calledOnce).to.be.true; + expect(removeCacheStub.calledOnce).to.be.true; + expect(listCachedSessionsStub.calledOnce).to.be.true; + expect((result as { ended: unknown[] }).ended).to.have.length(1); + }); + + it('returns empty ended list when user declines the confirmation prompt (all-agents path)', async () => { + listCachedSessionsStub.resolves([ + { + agentId: 'Declined_Agent', + sessions: [{ sessionId: 'dec-sess-1', sessionType: 'simulated' }], + }, + ]); + confirmStub.resolves(false); + + const result = await AgentPreviewEnd.run(['--all', '--target-org', 'test@org.com']); + + expect(confirmStub.calledOnce).to.be.true; + expect(result).to.deep.equal({ ended: [] }); + expect(removeCacheStub.called).to.be.false; + }); + + it('records failed sessions for the entry where Agent.init throws and succeeds for the other (all-agents path)', async () => { + listCachedSessionsStub.resolves([ + { + agentId: 'Good_Agent', + sessions: [{ sessionId: 'good-sess-1', sessionType: 'simulated' }], + }, + { + agentId: 'Bad_Agent', + sessions: [{ sessionId: 'bad-sess-1', sessionType: 'simulated' }], + }, + ]); + // Reset the beforeEach default behaviour so per-call setup below takes effect. + initStub.reset(); + const MockScriptAgent = class { + public preview = { end: agentPreviewEndStub }; + public setSessionId = sinon.stub(); + public getHistoryDir = sinon.stub().resolves(TRACES_PATH); + public getAgentIdForStorage = sinon.stub().returns(AGENT_ID); + }; + const mockInstance = new MockScriptAgent(); + initStub.onFirstCall().resolves(mockInstance).onSecondCall().rejects(new Error('init failed')); + + try { + await AgentPreviewEnd.run(['--all', '--target-org', 'test@org.com', '--no-prompt']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + const err = error as any; + expect(err.name).to.equal('PreviewEndPartialFailure'); + expect(err.message).to.include('Failed to end 1 session(s)'); + expect(err.message).to.include('bad-sess-1'); + expect(err.message).to.include('init failed'); + expect(err.message).to.include('Successfully ended 1 session(s)'); + expect(err.message).to.include('good-sess-1'); + } + }); + }); +}); diff --git a/test/nuts/z3.agent.preview.nut.ts b/test/nuts/z3.agent.preview.nut.ts index cbaf7724..a54955ee 100644 --- a/test/nuts/z3.agent.preview.nut.ts +++ b/test/nuts/z3.agent.preview.nut.ts @@ -80,7 +80,7 @@ describe('agent preview', function () { const endResult = execCmd( `agent preview end --session-id ${sessionId} --authoring-bundle ${bundleApiName} --target-org ${targetOrg} --json` - ).jsonOutput?.result; + ).jsonOutput?.result as import('../../src/commands/agent/preview/end.js').EndedSession | undefined; expect(endResult?.sessionId).to.equal(sessionId); expect(endResult?.tracesPath).to.be.a('string').and.include('.sfdx').and.include('agents'); }); @@ -154,7 +154,7 @@ describe('agent preview', function () { `agent preview end --session-id ${sessionId} --api-name ${ publishedAgent!.DeveloperName } --target-org ${targetOrg} --json` - ).jsonOutput?.result; + ).jsonOutput?.result as { sessionId?: string; tracesPath?: string } | undefined; expect(endResult?.sessionId).to.equal(sessionId); expect(endResult?.tracesPath).to.be.a('string'); });