From 85bc9ee13a58490a3bd886837beecfc02e9e2619 Mon Sep 17 00:00:00 2001 From: Francisco Sammartino Date: Tue, 28 Apr 2026 13:58:45 -0300 Subject: [PATCH 1/2] feat: add --all flag to agent preview end (W-22203669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds --all to end multiple preview sessions at once - When combined with --api-name or --authoring-bundle, ends only sessions for that specific agent; when used alone, ends all sessions in the project - Adds --no-prompt (-p) to skip the confirmation prompt shown by --all - Makes --target-org optional (no org needed for client-side session cleanup); raises a clear error when --api-name is used without --target-org - Restores inline previewSessionStore implementation (the @salesforce/agents shim was broken against the installed 1.1.1 version); adds getSessionDir and removeCacheById helpers needed by the --all code path - Adds 13 unit tests for the new end command behaviour Co-Authored-By: Claude Sonnet 4.6 fix: address review findings for --all flag on agent preview end - Add try/catch in endAll serial loop with structured PreviewEndPartialFailure error listing which sessions failed vs succeeded - Restore timestamp/sessionType columns on agent preview sessions (W-22203667 regression introduced by the shim revert in the previous commit) - Add three missing tests: --all+--api-name happy path, missing --target-org guard, and mid-loop failure with partial results assertions - Document --no-prompt only has effect when used with --all Co-Authored-By: Claude Sonnet 4.6 chore: regenerate schemas and fix NUT type assertions for EndedSession union Co-Authored-By: Claude Sonnet 4.6 fix: restore SessionType named import in start.ts Co-Authored-By: Claude Sonnet 4.6 refactor: restore pre-shim inline session store; scope to W-22203669 only Replace the bloated inline rewrite with the correct base (commit 57fb5f7, the last working inline implementation before the broken shim). Only the two helpers needed by --all are added on top: getSessionDir and removeCacheById. sessions.ts and start.ts are restored to their main state. Co-Authored-By: Claude Sonnet 4.6 fix: remove agentId from EndedSession public result type agentId was not in the original result type and is not needed by callers. Kept as an internal SessionTask type for routing within endAll. Co-Authored-By: Claude Sonnet 4.6 refactor: enforce --target-org dependency on --api-name at flag level Use oclif dependsOn instead of a manual guard in run(). authoring-bundle does not need target-org since it works client-side only. Co-Authored-By: Claude Sonnet 4.6 refactor: extract callPreviewEnd to remove instanceof duplication The ScriptAgent/ProductionAgent branching was identical in the single-session path and the endAll loop. Extracted to a module-level function. Co-Authored-By: Claude Sonnet 4.6 chore: restore atomic-write comment on updateSessionIndex Co-Authored-By: Claude Sonnet 4.6 refactor: eliminate getSessionDir/removeCacheById in favor of duck-typed removeCache removeCache only needs getHistoryDir(), so the no-agent path in --all can pass a plain object instead of requiring a separate removeCacheById helper. Loosened removeCache/validatePreviewSession signatures to structural types. Co-Authored-By: Claude Sonnet 4.6 chore: restore previewSessionStore.ts to main shim and upgrade @salesforce/agents to 1.2.0 The shim was inadvertently replaced with a full inline implementation. Restoring it to the re-export shim from main. Bumping agents to 1.2.0 which now exports the session store functions the shim references. Co-Authored-By: Claude Sonnet 4.6 chore: restore yarn.lock to main state Co-Authored-By: Claude Sonnet 4.6 Apply suggestion from @jshackell-sfdc Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com> Apply suggestion from @jshackell-sfdc Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com> refactor: use Interfaces.InferredFlags for private method flag types Replaces manual inline object types with Pick so the method signatures stay in sync with the flag definitions automatically. Co-Authored-By: Claude Sonnet 4.6 refactor: require --api-name or --authoring-bundle when using --all --all alone was only doing client-side cleanup for ProductionAgent sessions, which was inconsistent. Now --all must be combined with an agent identifier so every session is properly ended server-side. Also simplifies endAll: SessionTask reduced to {sessionId}, tracesPath resolved via agent.getHistoryDir() inside the loop, prompt updated to name the specific agent rather than a generic count. Co-Authored-By: Claude Sonnet 4.6 refactor: enforce --all agent identifier requirement at flag definition level Moves the --all + agent identifier constraint from a runtime guard to oclif's atLeastOne flag metadata. Removing 'all' from all three atLeastOne arrays means oclif itself rejects --all alone with a single clean error message, rather than a custom SfError with duplicated validation logic in run(). Co-Authored-By: Claude Sonnet 4.6 refactor: use exactlyOne instead of exclusive+atLeastOne for agent identifier flags Matches the pattern already used in start.ts. exactlyOne is cleaner and expresses the intent in a single declaration — exactly one of --api-name or --authoring-bundle must be provided. Co-Authored-By: Claude Sonnet 4.6 feat: allow --all alone to end sessions across all cached agents When --all is used without --api-name or --authoring-bundle, reads all active sessions from the local cache via listCachedSessions, determines agent type from the cached sessionType, and ends each session properly (server-side for ProductionAgent, local for ScriptAgent). --target-org is required with --all since it may be needed for server-side calls. Co-Authored-By: Claude Sonnet 4.6 fix: address PR review feedback and improve --all end-all-agents path - Remove all explicit Lifecycle.emitTelemetry() calls (CLI framework handles it) - Change PreviewEndPartialFailure exit code from 4 to 6 - Use sessionType as discriminator for agent type (published vs local) - Re-set sessionId after ProductionAgent.endSession() clears it before removeCache - Clean local cache on server error for --all paths to prevent stale buildup - Show per-agent breakdown with local/published label in confirmation prompt - Use displayName in prompt breakdown, falling back to agentId Co-Authored-By: Claude Sonnet 4.6 fix: apply Slack feedback — exit code 68, bot/bundle labels, no cache cleanup on failure - PreviewEndPartialFailure exit code 6 → 68 (matches deploy-retrieve SucceededPartial) - Label published sessions as 'bot', AAB sessions as 'bundle' in --all prompt breakdown - Remove cleanOnServerError: failed sessions no longer remove local cache - Narrow endAllAgents noPrompt param from boolean | undefined to boolean - Fix finishEndAll JSDoc to mention both callers - Add tests: exitCode 68 assertion, all-agents prompt confirm/decline, Agent.init failure Co-Authored-By: Claude Sonnet 4.6 fix: show user-supplied flag value in --all prompt instead of internal bot ID --all --api-name was showing the internal Salesforce Bot record ID (e.g. 0Xxg8000000QnQ5CAK) in the confirmation prompt instead of the API name passed by the user. Use flags['api-name'] ?? flags['authoring-bundle'] as the prompt label in endAllForAgent. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +- command-snapshot.json | 14 +- messages/agent.preview.end.md | 38 +- schemas/agent-preview-end.json | 20 + src/commands/agent/preview/end.ts | 294 ++++++++++-- test/commands/agent/preview/end.test.ts | 565 ++++++++++++++++++++++++ test/nuts/z3.agent.preview.nut.ts | 4 +- 7 files changed, 908 insertions(+), 44 deletions(-) create mode 100644 test/commands/agent/preview/end.test.ts 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..20083c61 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 agentLabel = flags['api-name'] ?? flags['authoring-bundle']!; + 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, agentLabel]), + }); + 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..3bd6597d --- /dev/null +++ b/test/commands/agent/preview/end.test.ts @@ -0,0 +1,565 @@ +/* + * 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('uses the --authoring-bundle flag value (not the internal storage ID) in the confirmation prompt', 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; + const promptMessage: string = confirmStub.firstCall.args[0].message as string; + expect(promptMessage).to.include('My_Local_Agent'); + expect(promptMessage).not.to.include(AGENT_ID); + }); + + it('uses the --api-name flag value (not the internal storage ID) in the confirmation prompt', async () => { + getCachedSessionIdsStub.resolves([SESSION_ID]); + confirmStub.resolves(true); + + await AgentPreviewEnd.run(['--all', '--api-name', 'My_Published_Agent', '--target-org', 'test@org.com']); + + expect(confirmStub.calledOnce).to.be.true; + const promptMessage: string = confirmStub.firstCall.args[0].message as string; + expect(promptMessage).to.include('My_Published_Agent'); + expect(promptMessage).not.to.include(AGENT_ID); + }); + + 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'); }); From 10198070b15f0a302328fb8a795eadafc72c3ab5 Mon Sep 17 00:00:00 2001 From: Francisco Sammartino Date: Fri, 8 May 2026 09:39:02 -0300 Subject: [PATCH 2/2] refactor: revert target-org to requiredOrg and simplify connection handling --target-org is now required globally. Remove dependsOn constraints on --api-name and --all (no longer needed), drop all Connection | undefined type guards and casts, and update tests to pass --target-org everywhere. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/agent/preview/end.ts | 24 +++++--------- test/commands/agent/preview/end.test.ts | 43 ++++++++++++++++--------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/commands/agent/preview/end.ts b/src/commands/agent/preview/end.ts index 20083c61..a756a9c9 100644 --- a/src/commands/agent/preview/end.ts +++ b/src/commands/agent/preview/end.ts @@ -69,7 +69,7 @@ export default class AgentPreviewEnd extends SfCommand { }); public static readonly flags = { - 'target-org': Flags.optionalOrg(), + 'target-org': Flags.requiredOrg(), 'api-version': Flags.orgApiVersion(), 'session-id': Flags.string({ summary: messages.getMessage('flags.session-id.summary'), @@ -80,7 +80,6 @@ export default class AgentPreviewEnd extends SfCommand { summary: messages.getMessage('flags.api-name.summary'), char: 'n', exclusive: ['authoring-bundle'], - dependsOn: ['target-org'], }), 'authoring-bundle': Flags.string({ summary: messages.getMessage('flags.authoring-bundle.summary'), @@ -89,7 +88,6 @@ export default class AgentPreviewEnd extends SfCommand { 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'), @@ -100,10 +98,9 @@ export default class AgentPreviewEnd extends SfCommand { public async run(): Promise { const { flags } = await this.parse(AgentPreviewEnd); - const conn = flags['target-org']?.getConnection(flags['api-version']); + 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); } @@ -174,20 +171,17 @@ export default class AgentPreviewEnd extends SfCommand { private async initAgent( flags: Pick, - conn: Connection | undefined + conn: Connection ): 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, + connection: conn, project: this.project!, aabName: flags['authoring-bundle'], }) - : await Agent.init({ connection: conn as Connection, project: this.project!, apiNameOrId: flags['api-name']! }); + : await Agent.init({ connection: conn, 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); @@ -196,16 +190,14 @@ export default class AgentPreviewEnd extends SfCommand { private async endAll( flags: Pick, - conn: Connection | undefined + conn: Connection ): 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.endAllForAgent(flags, conn); } - return this.endAllAgents(connection, flags['no-prompt'] ?? false); + return this.endAllAgents(conn, flags['no-prompt'] ?? false); } /** diff --git a/test/commands/agent/preview/end.test.ts b/test/commands/agent/preview/end.test.ts index 3bd6597d..ad93ce72 100644 --- a/test/commands/agent/preview/end.test.ts +++ b/test/commands/agent/preview/end.test.ts @@ -98,7 +98,12 @@ describe('agent preview end', () => { 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']); + const result = await AgentPreviewEnd.run([ + '--authoring-bundle', + 'My_Local_Agent', + '--target-org', + 'test@org.com', + ]); expect(initStub.calledOnce).to.be.true; expect(validatePreviewSessionStub.calledOnce).to.be.true; @@ -115,6 +120,8 @@ describe('agent preview end', () => { 'My_Local_Agent', '--session-id', explicitSessionId, + '--target-org', + 'test@org.com', ]); expect(getCachedSessionIdsStub.called).to.be.false; @@ -125,7 +132,7 @@ describe('agent preview end', () => { getCachedSessionIdsStub.resolves([]); try { - await AgentPreviewEnd.run(['--authoring-bundle', 'My_Local_Agent']); + await AgentPreviewEnd.run(['--authoring-bundle', 'My_Local_Agent', '--target-org', 'test@org.com']); expect.fail('Expected an error to be thrown'); } catch (error: unknown) { expect((error as Error).message).to.include('No agent preview session found'); @@ -136,25 +143,16 @@ describe('agent preview end', () => { getCachedSessionIdsStub.resolves(['session-1', 'session-2']); try { - await AgentPreviewEnd.run(['--authoring-bundle', 'My_Local_Agent']); + await AgentPreviewEnd.run(['--authoring-bundle', 'My_Local_Agent', '--target-org', 'test@org.com']); 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([]); + await AgentPreviewEnd.run(['--target-org', 'test@org.com']); expect.fail('Expected an error to be thrown'); } catch (error: unknown) { expect((error as Error).message).to.match( @@ -165,7 +163,14 @@ describe('agent preview end', () => { 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']); + await AgentPreviewEnd.run([ + '--api-name', + 'My_Published_Agent', + '--authoring-bundle', + 'My_Local_Agent', + '--target-org', + 'test@org.com', + ]); 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); @@ -174,7 +179,15 @@ describe('agent preview end', () => { it('throws when --session-id and --all are both provided', async () => { try { - await AgentPreviewEnd.run(['--authoring-bundle', 'My_Local_Agent', '--session-id', 'sid', '--all']); + await AgentPreviewEnd.run([ + '--authoring-bundle', + 'My_Local_Agent', + '--session-id', + 'sid', + '--all', + '--target-org', + 'test@org.com', + ]); expect.fail('Expected an error to be thrown'); } catch (error: unknown) { expect((error as Error).message).to.match(/cannot also be provided/i);