Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
07838d9
feat: add --all flag to agent preview end (W-22203669)
franciscoperezsammartino Apr 28, 2026
c0f8d35
fix: address review findings for --all flag on agent preview end
franciscoperezsammartino Apr 28, 2026
cd0f110
chore: regenerate schemas and fix NUT type assertions for EndedSessio…
franciscoperezsammartino Apr 28, 2026
2b06552
fix: restore SessionType named import in start.ts
franciscoperezsammartino Apr 28, 2026
1c51088
refactor: restore pre-shim inline session store; scope to W-22203669 …
franciscoperezsammartino Apr 28, 2026
2aa93f1
fix: remove agentId from EndedSession public result type
franciscoperezsammartino Apr 28, 2026
0336fb9
refactor: enforce --target-org dependency on --api-name at flag level
franciscoperezsammartino Apr 28, 2026
3ea4c26
refactor: extract callPreviewEnd to remove instanceof duplication
franciscoperezsammartino Apr 28, 2026
f54e906
chore: restore atomic-write comment on updateSessionIndex
franciscoperezsammartino Apr 28, 2026
6f64ff5
refactor: eliminate getSessionDir/removeCacheById in favor of duck-ty…
franciscoperezsammartino Apr 28, 2026
26666d1
chore: restore previewSessionStore.ts to main shim and upgrade @sales…
franciscoperezsammartino Apr 28, 2026
4c9f795
chore: restore yarn.lock to main state
franciscoperezsammartino Apr 28, 2026
6716566
Apply suggestion from @jshackell-sfdc
franciscoperezsammartino Apr 28, 2026
10fe754
Apply suggestion from @jshackell-sfdc
franciscoperezsammartino Apr 28, 2026
b9a6a13
refactor: use Interfaces.InferredFlags for private method flag types
franciscoperezsammartino Apr 28, 2026
15b796d
refactor: require --api-name or --authoring-bundle when using --all
franciscoperezsammartino Apr 29, 2026
9553aca
refactor: enforce --all agent identifier requirement at flag definiti…
franciscoperezsammartino Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down
30 changes: 28 additions & 2 deletions messages/agent.preview.end.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 together with either --api-name or --authoring-bundle to end all active preview sessions for a specific agent at once.

# flags.session-id.summary

Expand All @@ -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 for the specified agent. Must be combined with --api-name or --authoring-bundle.

# 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.
Expand All @@ -44,9 +54,21 @@ 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?

# 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 <SESSION_ID> --api-name My_Published_Agent

Expand All @@ -57,3 +79,7 @@ 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 --no-prompt
20 changes: 20 additions & 0 deletions schemas/agent-preview-end.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
158 changes: 132 additions & 26 deletions src/commands/agent/preview/end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,33 @@
* limitations under the License.
*/

import { Flags, SfCommand, toHelpSection } from '@salesforce/sf-plugins-core';
import { Flags, SfCommand, toHelpSection, prompts } from '@salesforce/sf-plugins-core';
import { Messages, SfError, Lifecycle, EnvironmentVariable } from '@salesforce/core';
import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents';
import type { Connection } from '@salesforce/core';
import type { Interfaces } from '@oclif/core';
import { getCachedSessionIds, removeCache, validatePreviewSession } 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<void> {
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<AgentPreviewEndResult> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
Expand All @@ -46,41 +60,47 @@ export default class AgentPreviewEnd extends SfCommand<AgentPreviewEndResult> {
});

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'],
atLeastOne: ['api-name', 'authoring-bundle'],
dependsOn: ['target-org'],
}),
'authoring-bundle': Flags.string({
summary: messages.getMessage('flags.authoring-bundle.summary'),
exactlyOne: ['api-name', 'authoring-bundle'],
exclusive: ['api-name'],
atLeastOne: ['api-name', 'authoring-bundle'],
}),
all: Flags.boolean({
summary: messages.getMessage('flags.all.summary'),
exclusive: ['session-id'],
atLeastOne: ['api-name', 'authoring-bundle'],
}),
'no-prompt': Flags.boolean({
summary: messages.getMessage('flags.no-prompt.summary'),
char: 'p',
}),
};

public async run(): Promise<AgentPreviewEndResult> {
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']) {
return this.endAll(flags, conn);
}

// 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);
Expand All @@ -102,7 +122,6 @@ export default class AgentPreviewEnd extends SfCommand<AgentPreviewEndResult> {

agent.setSessionId(sessionId);

// Validate session
try {
await validatePreviewSession(agent);
} catch (error) {
Expand All @@ -120,13 +139,8 @@ export default class AgentPreviewEnd extends SfCommand<AgentPreviewEndResult> {
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' });
Expand All @@ -140,8 +154,100 @@ export default class AgentPreviewEnd extends SfCommand<AgentPreviewEndResult> {
}

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<CommandFlags, 'api-name' | 'authoring-bundle'>,
conn: Connection | undefined
): Promise<ScriptAgent | ProductionAgent> {
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);
await Lifecycle.getInstance().emitTelemetry({ eventName: 'agent_preview_end_agent_not_found' });
throw new SfError(messages.getMessage('error.agentNotFound', [agentIdentifier]), 'AgentNotFound', [], 2, wrapped);
}
}

private async endAll(
flags: Pick<CommandFlags, 'api-name' | 'authoring-bundle' | 'no-prompt'>,
conn: Connection | undefined
): 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: [] };
}
}

// Process sessions serially so that agent.setSessionId() / agent.preview.end() calls on the
// shared agent object do not race with each other.
const ended: EndedSession[] = [];
const failed: Array<{ task: SessionTask; error: string }> = [];

for (const task of sessionsToEnd) {
const { sessionId } = task;
try {
// ScriptAgent flushes traces to disk; ProductionAgent issues the server-side request.
agent.setSessionId(sessionId);
// eslint-disable-next-line no-await-in-loop
const tracesPath = await agent.getHistoryDir();
// eslint-disable-next-line no-await-in-loop
await callPreviewEnd(agent);
// 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 });
}
}

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}]` : ''}.`;
await Lifecycle.getInstance().emitTelemetry({
eventName: 'agent_preview_end_all_partial_failure',
failedCount: failed.length,
succeededCount: ended.length,
});
throw new SfError(msg, 'PreviewEndPartialFailure', [], 4);
}

await Lifecycle.getInstance().emitTelemetry({
eventName: 'agent_preview_end_all_success',
sessionCount: ended.length,
});
this.log(messages.getMessage('output.endedAll', [ended.length]));
return { ended };
}
}

type CommandFlags = Interfaces.InferredFlags<typeof AgentPreviewEnd.flags>;
Loading
Loading