diff --git a/command-snapshot.json b/command-snapshot.json index bc0fe751..768826f3 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -92,6 +92,47 @@ ], "plugin": "@salesforce/plugin-agent" }, + { + "alias": [], + "command": "agent:preview:end", + "flagAliases": [], + "flagChars": ["n", "o"], + "flags": ["api-name", "api-version", "authoring-bundle", "flags-dir", "json", "session-id", "target-org"], + "plugin": "@salesforce/plugin-agent" + }, + { + "alias": [], + "command": "agent:preview:send", + "flagAliases": [], + "flagChars": ["n", "o", "u"], + "flags": [ + "api-name", + "api-version", + "authoring-bundle", + "flags-dir", + "json", + "session-id", + "target-org", + "utterance" + ], + "plugin": "@salesforce/plugin-agent" + }, + { + "alias": [], + "command": "agent:preview:sessions", + "flagAliases": [], + "flagChars": [], + "flags": ["flags-dir", "json"], + "plugin": "@salesforce/plugin-agent" + }, + { + "alias": [], + "command": "agent:preview:start", + "flagAliases": [], + "flagChars": ["n", "o"], + "flags": ["api-name", "api-version", "authoring-bundle", "flags-dir", "json", "target-org", "use-live-actions"], + "plugin": "@salesforce/plugin-agent" + }, { "alias": [], "command": "agent:publish:authoring-bundle", diff --git a/messages/agent.preview.end.md b/messages/agent.preview.end.md new file mode 100644 index 00000000..ad25725d --- /dev/null +++ b/messages/agent.preview.end.md @@ -0,0 +1,45 @@ +# summary + +End a preview session and get trace location (beta). + +# description + +End an existing preview session and print the local path where session traces are stored. Use the session ID returned by "agent preview start". Specify the same agent with --api-name or --authoring-bundle as used when starting the session; one is required. + +# flags.session-id.summary + +Session ID from "agent preview start". Omit when the agent has exactly one active session. + +# flags.api-name.summary + +API name or ID of the published agent. + +# flags.authoring-bundle.summary + +API name of the authoring bundle (Agent Script). + +# error.noSession + +No preview session found. Run "sf agent preview start" first. + +# error.multipleSessions + +Multiple preview sessions found for this agent. Specify --session-id. Sessions: %s + +# output.tracesPath + +Session traces: %s + +# examples + +- End the single active preview session for a simulated agent: + + <%= config.bin %> <%= command.id %> --target-org my-dev-org --authoring-bundle My_Local + +- End the single active preview session for a published agent: + +<%= config.bin %> <%= command.id %> --target-org my-dev-org --api-name My_Published_Agent + +- End a specific session when multiple exist: + + <%= config.bin %> <%= command.id %> --session-id --target-org my-dev-org diff --git a/messages/agent.preview.send.md b/messages/agent.preview.send.md new file mode 100644 index 00000000..187d4fca --- /dev/null +++ b/messages/agent.preview.send.md @@ -0,0 +1,45 @@ +# summary + +Send a message in a preview session (beta). + +# description + +Send an utterance to an existing preview session and get the agent response. Use the session ID returned by "agent preview start". Specify the same agent with --api-name or --authoring-bundle as used when starting the session; one is required. + +# flags.session-id.summary + +Session ID from "agent preview start". Omit when the agent has exactly one active session. + +# flags.utterance.summary + +Utterance to send to the agent. + +# flags.api-name.summary + +API name or ID of the published agent. + +# flags.authoring-bundle.summary + +API name of the authoring bundle (Agent Script). + +# error.noSession + +No preview session found. Run "sf agent preview start" first. + +# error.multipleSessions + +Multiple preview sessions found for this agent. Specify --session-id. Sessions: %s + +# examples + +- Send a message to a simulated Agent: + + <%= config.bin %> <%= command.id %> --utterance "What can you help me with?" --authoring-bundle My_Agent + +Send a message to a published Agent: + +<%= config.bin %> <%= command.id %> --utterance "What can you help me with?" --api-name My_Published_Agent + +- Send to a specific session when multiple exist: + + <%= config.bin %> <%= command.id %> --session-id --utterance "What can you help me with?" --target-org my-dev-org diff --git a/messages/agent.preview.sessions.md b/messages/agent.preview.sessions.md new file mode 100644 index 00000000..ac064d1f --- /dev/null +++ b/messages/agent.preview.sessions.md @@ -0,0 +1,25 @@ +# summary + +List cached preview sessions (beta). + +# description + +List preview sessions that were started with "agent preview start" and are still in the cache. Use this to see which sessions exist so you can end them with "agent preview end" (e.g. to clean up or resolve "multiple sessions" when using send without --session-id). Agent ID is the authoring bundle name for Agent Script agents, or the agent ID for published agents. + +# output.empty + +No cached preview sessions found. + +# output.tableHeader.agent + +Agent (authoring bundle or API name) + +# output.tableHeader.sessionId + +Session ID + +# examples + +- List all cached preview sessions: + + <%= config.bin %> <%= command.id %> diff --git a/messages/agent.preview.start.md b/messages/agent.preview.start.md new file mode 100644 index 00000000..4a70b001 --- /dev/null +++ b/messages/agent.preview.start.md @@ -0,0 +1,37 @@ +# summary + +Start a programmatic preview session (beta). + +# description + +Start an agent preview session and get a session ID. Use the session ID with "agent preview send" and "agent preview end". Specify the agent with --api-name (published agent) or --authoring-bundle (Agent Script); one is required. Use --use-live-actions for live mode; otherwise preview uses mock (simulated) actions. + +# flags.api-name.summary + +API name or ID of the published agent. + +# flags.authoring-bundle.summary + +API name of the authoring bundle (Agent Script) to preview. + +# flags.use-live-actions.summary + +Use real actions in the org; if not specified, preview uses mock (simulated) actions. + +# output.sessionId + +Session ID: %s + +# examples + +- Start a preview session with an authoring bundle and use mock actions: + + <%= config.bin %> <%= command.id %> --authoring-bundle My_Agent_Bundle --target-org my-dev-org + +- Start a preview session with an authoring bundle and use real actions: + + <%= config.bin %> <%= command.id %> --authoring-bundle My_Agent_Bundle --use-live-actions --target-org my-dev-org + +- Start a preview session with a published agent: + + <%= config.bin %> <%= command.id %> --api-name My_Published_Agent diff --git a/package.json b/package.json index 73654f3b..7f228432 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@inquirer/prompts": "^7.10.1", "@oclif/core": "^4", "@oclif/multi-stage-output": "^0.8.29", - "@salesforce/agents": "^0.22.1", + "@salesforce/agents": "^0.22.4", "@salesforce/core": "^8.24.3", "@salesforce/kit": "^3.2.4", "@salesforce/sf-plugins-core": "^12.2.6", diff --git a/schemas/agent-preview-end.json b/schemas/agent-preview-end.json new file mode 100644 index 00000000..9ce8737d --- /dev/null +++ b/schemas/agent-preview-end.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentPreviewEndResult", + "definitions": { + "AgentPreviewEndResult": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "tracesPath": { + "type": "string" + } + }, + "required": ["sessionId", "tracesPath"], + "additionalProperties": false + } + } +} diff --git a/schemas/agent-preview-send.json b/schemas/agent-preview-send.json new file mode 100644 index 00000000..ad6d5d9a --- /dev/null +++ b/schemas/agent-preview-send.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentPreviewSendResult", + "definitions": { + "AgentPreviewSendResult": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "required": ["messages"], + "additionalProperties": false + } + } +} diff --git a/schemas/agent-preview-sessions.json b/schemas/agent-preview-sessions.json new file mode 100644 index 00000000..37c008c0 --- /dev/null +++ b/schemas/agent-preview-sessions.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentPreviewSessionsResult", + "definitions": { + "AgentPreviewSessionsResult": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agentId": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": ["agentId", "sessionId"], + "additionalProperties": false + } + } + } +} diff --git a/schemas/agent-preview-start.json b/schemas/agent-preview-start.json new file mode 100644 index 00000000..f608d52f --- /dev/null +++ b/schemas/agent-preview-start.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentPreviewStartResult", + "definitions": { + "AgentPreviewStartResult": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": ["sessionId"], + "additionalProperties": false + } + } +} diff --git a/src/commands/agent/preview/end.ts b/src/commands/agent/preview/end.ts new file mode 100644 index 00000000..6d66cae7 --- /dev/null +++ b/src/commands/agent/preview/end.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; +import { Messages, SfError } from '@salesforce/core'; +import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; +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 = { + sessionId: string; + tracesPath: string; +}; + +export default class AgentPreviewEnd extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'beta'; + public static readonly requiresProject = true; + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'session-id': Flags.string({ + summary: messages.getMessage('flags.session-id.summary'), + required: false, + }), + 'api-name': Flags.string({ + summary: messages.getMessage('flags.api-name.summary'), + char: 'n', + exactlyOne: ['api-name', 'authoring-bundle'], + }), + 'authoring-bundle': Flags.string({ + summary: messages.getMessage('flags.authoring-bundle.summary'), + exactlyOne: ['api-name', 'authoring-bundle'], + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(AgentPreviewEnd); + + const conn = flags['target-org'].getConnection(flags['api-version']); + const 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']! }); + + let sessionId = flags['session-id']; + if (sessionId === undefined) { + const cached = await getCachedSessionIds(this.project!, agent); + if (cached.length === 0) { + throw new SfError(messages.getMessage('error.noSession'), 'PreviewSessionNotFound'); + } + if (cached.length > 1) { + throw new SfError( + messages.getMessage('error.multipleSessions', [cached.join(', ')]), + 'PreviewSessionAmbiguous' + ); + } + sessionId = cached[0]; + } + agent.setSessionId(sessionId); + await validatePreviewSession(agent); + + const tracesPath = await agent.getHistoryDir(); + + await removeCache(agent); + + if (agent instanceof ScriptAgent) { + await agent.preview.end(); + } else if (agent instanceof ProductionAgent) { + await agent.preview.end('UserRequest'); + } + const result = { sessionId, tracesPath }; + this.log(messages.getMessage('output.tracesPath', [tracesPath])); + return result; + } +} diff --git a/src/commands/agent/preview/send.ts b/src/commands/agent/preview/send.ts new file mode 100644 index 00000000..a87dc959 --- /dev/null +++ b/src/commands/agent/preview/send.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; +import { Messages, SfError } from '@salesforce/core'; +import { Agent } from '@salesforce/agents'; +import { getCachedSessionIds, validatePreviewSession } from '../../../previewSessionStore.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.send'); + +export type AgentPreviewSendResult = { + messages: Array<{ message?: string; role?: string }>; +}; + +export default class AgentPreviewSend extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'beta'; + public static readonly requiresProject = true; + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'session-id': Flags.string({ + summary: messages.getMessage('flags.session-id.summary'), + required: false, + }), + utterance: Flags.string({ + summary: messages.getMessage('flags.utterance.summary'), + required: true, + char: 'u', + }), + 'api-name': Flags.string({ + summary: messages.getMessage('flags.api-name.summary'), + char: 'n', + exactlyOne: ['api-name', 'authoring-bundle'], + }), + 'authoring-bundle': Flags.string({ + summary: messages.getMessage('flags.authoring-bundle.summary'), + exactlyOne: ['api-name', 'authoring-bundle'], + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(AgentPreviewSend); + + const conn = flags['target-org'].getConnection(flags['api-version']); + + const 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']! }); + + let sessionId = flags['session-id']; + if (sessionId === undefined) { + const cached = await getCachedSessionIds(this.project!, agent); + if (cached.length === 0) { + throw new SfError(messages.getMessage('error.noSession'), 'PreviewSessionNotFound'); + } + if (cached.length > 1) { + throw new SfError( + messages.getMessage('error.multipleSessions', [cached.join(', ')]), + 'PreviewSessionAmbiguous' + ); + } + sessionId = cached[0]; + } + agent.setSessionId(sessionId); + await validatePreviewSession(agent); + + const response = await agent.preview.send(flags.utterance); + this.log(response.messages[0].message); + return { messages: response.messages ?? [] }; + } +} diff --git a/src/commands/agent/preview/sessions.ts b/src/commands/agent/preview/sessions.ts new file mode 100644 index 00000000..dac20a41 --- /dev/null +++ b/src/commands/agent/preview/sessions.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SfCommand } from '@salesforce/sf-plugins-core'; +import { Messages } from '@salesforce/core'; +import { listCachedSessions } from '../../../previewSessionStore.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.sessions'); + +export type AgentPreviewSessionsResult = Array<{ agentId: string; displayName?: string; sessionId: string }>; + +export default class AgentPreviewSessions extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'beta'; + public static readonly requiresProject = true; + + public async run(): Promise { + const entries = await listCachedSessions(this.project!); + const rows: AgentPreviewSessionsResult = []; + for (const { agentId, displayName, sessionIds } of entries) { + for (const sessionId of sessionIds) { + rows.push({ agentId, displayName, sessionId }); + } + } + + if (rows.length === 0) { + this.log(messages.getMessage('output.empty')); + return []; + } + + if (this.jsonEnabled()) { + return rows; + } + + const agentColumnHeader = messages.getMessage('output.tableHeader.agent'); + const sessionIdHeader = messages.getMessage('output.tableHeader.sessionId'); + const tableData = rows.map((r) => ({ + agent: r.displayName ?? r.agentId, + sessionId: r.sessionId, + })); + this.table({ + data: tableData, + columns: [ + { key: 'agent', name: agentColumnHeader }, + { key: 'sessionId', name: sessionIdHeader }, + ], + }); + return rows; + } +} diff --git a/src/commands/agent/preview/start.ts b/src/commands/agent/preview/start.ts new file mode 100644 index 00000000..6b638f29 --- /dev/null +++ b/src/commands/agent/preview/start.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; +import { Lifecycle, Messages } from '@salesforce/core'; +import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; +import { createCache } from '../../../previewSessionStore.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.start'); + +export type AgentPreviewStartResult = { + sessionId: string; +}; + +export default class AgentPreviewStart extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'beta'; + public static readonly requiresProject = true; + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'api-name': Flags.string({ + summary: messages.getMessage('flags.api-name.summary'), + char: 'n', + exactlyOne: ['api-name', 'authoring-bundle'], + }), + 'authoring-bundle': Flags.string({ + summary: messages.getMessage('flags.authoring-bundle.summary'), + exactlyOne: ['api-name', 'authoring-bundle'], + }), + 'use-live-actions': Flags.boolean({ + summary: messages.getMessage('flags.use-live-actions.summary'), + default: false, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(AgentPreviewStart); + const conn = flags['target-org'].getConnection(flags['api-version']); + const useLiveActions = flags['use-live-actions']; + + const 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']! }); + if (agent instanceof ScriptAgent) { + agent.preview.setMockMode(useLiveActions ? 'Live Test' : 'Mock'); + } + + if (useLiveActions && agent instanceof ProductionAgent) { + void Lifecycle.getInstance().emitWarning( + 'Published agents always use real actions; --use-live-actions has no effect for published agents.' + ); + } + + const session = await agent.preview.start(); + const displayName = flags['authoring-bundle'] ?? flags['api-name']; + await createCache(agent, { displayName }); + + const result: AgentPreviewStartResult = { sessionId: session.sessionId }; + this.log(messages.getMessage('output.sessionId', [session.sessionId])); + return result; + } +} diff --git a/src/components/agent-preview-react.tsx b/src/components/agent-preview-react.tsx index a6095b3c..2221f5ee 100644 --- a/src/components/agent-preview-react.tsx +++ b/src/components/agent-preview-react.tsx @@ -125,7 +125,7 @@ export function AgentPreviewReact(props: { if (isLocalAgent) { await agent.end(); } else { - await (agent as ProductionAgentPreview).end('UserRequest'); + await agent.end('UserRequest'); } process.exit(0); } catch (e) { diff --git a/src/previewSessionStore.ts b/src/previewSessionStore.ts new file mode 100644 index 00000000..a6593d84 --- /dev/null +++ b/src/previewSessionStore.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { SfError } from '@salesforce/core'; +import type { SfProject } from '@salesforce/core'; +import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; + +const SESSION_META_FILE = 'session-meta.json'; + +export type SessionMeta = { displayName?: string }; + +/** + * Save a marker so send/end can validate that the session was started for this agent. + * Caller must have started the session (agent has sessionId set). Uses agent.getHistoryDir() for the path. + * Pass displayName (authoring bundle name or production agent API name) so "agent preview sessions" can show it. + */ +export async function createCache( + agent: ScriptAgent | ProductionAgent, + options?: { displayName?: string } +): Promise { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ + const historyDir = await agent.getHistoryDir(); + const metaPath = join(historyDir, SESSION_META_FILE); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ + const meta: SessionMeta = { displayName: options?.displayName }; + await writeFile(metaPath, JSON.stringify(meta), 'utf-8'); +} + +/** + * Validate that the session was started for this agent (marker file exists in agent's history dir for current sessionId). + * Caller must set sessionId on the agent (agent.setSessionId) before calling. + * Throws SfError if the session marker is not found. + */ +export async function validatePreviewSession(agent: ScriptAgent | ProductionAgent): Promise { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ + const historyDir = await agent.getHistoryDir(); + const metaPath = join(historyDir, SESSION_META_FILE); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ + try { + await readFile(metaPath, 'utf-8'); + } catch { + throw new SfError( + 'No preview session found for this session ID. Run "sf agent preview start" first.', + 'PreviewSessionNotFound' + ); + } +} + +/** + * Remove the session marker so this session is no longer considered "active" for send/end without --session-id. + * Call after ending the session. Caller must set sessionId on the agent before calling. + */ +export async function removeCache(agent: ScriptAgent | ProductionAgent): Promise { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ + const historyDir = await agent.getHistoryDir(); + const metaPath = join(historyDir, SESSION_META_FILE); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ + try { + await unlink(metaPath); + } catch { + // already removed or never created + } +} + +/** + * List session IDs that have a cache marker (started via "agent preview start") for this agent. + * Uses project path and agent's storage ID to find .sfdx/agents//sessions//session-meta.json. + */ +export async function getCachedSessionIds(project: SfProject, agent: ScriptAgent | ProductionAgent): Promise { + const agentId = agent.getAgentIdForStorage(); + const base = join(project.getPath(), '.sfdx'); + const sessionsDir = join(base, 'agents', agentId, 'sessions'); + const sessionIds: string[] = []; + try { + const entries = await readdir(sessionsDir, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + const hasMarker = await Promise.all( + dirs.map(async (name) => { + try { + await readFile(join(sessionsDir, name, SESSION_META_FILE), 'utf-8'); + return true; + } catch { + return false; + } + }) + ); + dirs.forEach((name, i) => { + if (hasMarker[i]) sessionIds.push(name); + }); + } catch { + // sessions dir missing or unreadable + } + return sessionIds; +} + +/** + * Return the single "current" session ID when safe: exactly one cached session for this agent. + * Returns undefined when there are zero or multiple sessions (caller should require --session-id). + */ +export async function getCurrentSessionId( + project: SfProject, + agent: ScriptAgent | ProductionAgent +): Promise { + const ids = await getCachedSessionIds(project, agent); + return ids.length === 1 ? ids[0] : undefined; +} + +export type CachedSessionEntry = { agentId: string; displayName?: string; sessionIds: string[] }; + +/** + * List all cached preview sessions in the project, grouped by agent ID. + * displayName (when present in session-meta.json) is the authoring bundle name or production agent API name for display. + * Use this to show users which sessions exist so they can end or clean up. + */ +export async function listCachedSessions(project: SfProject): Promise { + const base = join(project.getPath(), '.sfdx', 'agents'); + const result: CachedSessionEntry[] = []; + try { + const agentDirs = await readdir(base, { withFileTypes: true }); + const entries = await Promise.all( + agentDirs + .filter((ent) => ent.isDirectory()) + .map(async (ent) => { + const agentId = ent.name; + const sessionsDir = join(base, agentId, 'sessions'); + let sessionIds: string[] = []; + let displayName: string | undefined; + try { + const sessionDirs = await readdir(sessionsDir, { withFileTypes: true }); + const withMarker = await Promise.all( + sessionDirs + .filter((s) => s.isDirectory()) + .map(async (s) => { + try { + await readFile(join(sessionsDir, s.name, SESSION_META_FILE), 'utf-8'); + return s.name; + } catch { + return null; + } + }) + ); + sessionIds = withMarker.filter((id): id is string => id !== null); + if (sessionIds.length > 0) { + try { + const raw = await readFile(join(sessionsDir, sessionIds[0], SESSION_META_FILE), 'utf-8'); + const meta = JSON.parse(raw) as SessionMeta; + displayName = meta.displayName; + } catch { + // ignore + } + } + } catch { + // no sessions dir or unreadable + } + return { agentId, displayName, sessionIds }; + }) + ); + result.push(...entries.filter((e) => e.sessionIds.length > 0)); + } catch { + // no agents dir or unreadable + } + return result; +} diff --git a/test/nuts/z3.agent.preview.nut.ts b/test/nuts/z3.agent.preview.nut.ts index dc7b15da..cd1f251c 100644 --- a/test/nuts/z3.agent.preview.nut.ts +++ b/test/nuts/z3.agent.preview.nut.ts @@ -17,7 +17,10 @@ import { expect } from 'chai'; import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; import { Agent } from '@salesforce/agents'; -import { Org, SfProject } from '@salesforce/core'; +import { Org } from '@salesforce/core'; +import type { AgentPreviewStartResult } from '../../src/commands/agent/preview/start.js'; +import type { AgentPreviewSendResult } from '../../src/commands/agent/preview/send.js'; +import type { AgentPreviewEndResult } from '../../src/commands/agent/preview/end.js'; import { getTestSession, getUsername } from './shared-setup.js'; /* eslint-disable no-console */ @@ -42,110 +45,97 @@ describe('agent preview', function () { execCmd(`agent preview --api-name ${invalidApiName} --target-org ${getUsername()}`, { ensureExitCode: 1 }); }); - describe('using agent library directly', function () { - it("should start,send,end a preview (AgentScript, preview API, mockMode = 'Mock'", async () => { + describe('using preview start/send/end commands', () => { + it('should start, send, end a preview (Agent Script, mock mode)', async function () { this.timeout(5 * 60 * 1000); // 5 minutes for this test const bundleApiName = 'Willie_Resort_Manager'; - const projectPath = session.project.dir; - - const org = await Org.create({ aliasOrUsername: getUsername() }); - const connection = org.getConnection(); - const project = await SfProject.resolve(projectPath); - - const agent = await Agent.init({ - connection, - project, - aabName: bundleApiName, - }); - - agent.preview.setMockMode('Mock'); - - // Start session - const previewSession = await agent.preview.start(); - expect(previewSession.sessionId).to.be.a('string'); - - // Send first message - const response1 = await agent.preview.send('What can you help me with?'); - expect(response1.messages).to.be.an('array').with.length.greaterThan(0); - - // Send second message - const response2 = await agent.preview.send('Tell me more'); - expect(response2.messages).to.be.an('array').with.length.greaterThan(0); - - // End session - await agent.preview.end(); + const targetOrg = getUsername(); + + const startResult = execCmd( + `agent preview start --authoring-bundle ${bundleApiName} --target-org ${targetOrg} --json` + ).jsonOutput?.result; + expect(startResult?.sessionId).to.be.a('string'); + const sessionId = startResult!.sessionId; + + const sendResult1 = execCmd( + `agent preview send --session-id ${sessionId} --authoring-bundle ${bundleApiName} --utterance "What can you help me with?" --target-org ${targetOrg} --json` + ).jsonOutput?.result; + expect(sendResult1?.messages).to.be.an('array').with.length.greaterThan(0); + + const sendResult2 = execCmd( + `agent preview send --session-id ${sessionId} --authoring-bundle ${bundleApiName} --utterance "Tell me more" --target-org ${targetOrg} --json` + ).jsonOutput?.result; + expect(sendResult2?.messages).to.be.an('array').with.length.greaterThan(0); + + const endResult = execCmd( + `agent preview end --session-id ${sessionId} --authoring-bundle ${bundleApiName} --target-org ${targetOrg} --json` + ).jsonOutput?.result; + expect(endResult?.sessionId).to.equal(sessionId); + expect(endResult?.tracesPath).to.be.a('string').and.include('.sfdx').and.include('agents'); }); - it("should start,send,end a preview (AgentScript, preview API, mockMode = 'Live Test'", async () => { + + it('should start, send, end a preview (Agent Script, live mode)', async function () { this.timeout(5 * 60 * 1000); // 5 minutes for this test const bundleApiName = 'Willie_Resort_Manager'; - const projectPath = session.project.dir; - - const org = await Org.create({ aliasOrUsername: getUsername() }); - const connection = org.getConnection(); - const project = await SfProject.resolve(projectPath); - - const agent = await Agent.init({ - connection, - project, - aabName: bundleApiName, - }); - - // Start session - const previewSession = await agent.preview.start(); - expect(previewSession.sessionId).to.be.a('string'); + const targetOrg = getUsername(); - // Send first message - const response1 = await agent.preview.send('What can you help me with?'); - expect(response1.messages).to.be.an('array').with.length.greaterThan(0); + const startResult = execCmd( + `agent preview start --authoring-bundle ${bundleApiName} --use-live-actions --target-org ${targetOrg} --json` + ).jsonOutput?.result; + expect(startResult?.sessionId).to.be.a('string'); + const sessionId = startResult!.sessionId; - // Send second message - const response2 = await agent.preview.send('Tell me more'); - expect(response2.messages).to.be.an('array').with.length.greaterThan(0); + const sendResult1 = execCmd( + `agent preview send --session-id ${sessionId} --authoring-bundle ${bundleApiName} --utterance "What can you help me with?" --target-org ${targetOrg} --json` + ).jsonOutput?.result; + expect(sendResult1?.messages).to.be.an('array').with.length.greaterThan(0); - // End session - await agent.preview.end(); + execCmd( + `agent preview end --session-id ${sessionId} --authoring-bundle ${bundleApiName} --target-org ${targetOrg} --json` + ); }); - it('should start,send,end a preview (Published) session', async () => { + it('should start, send, end a preview (Published agent)', async function () { this.timeout(5 * 60 * 1000); // 5 minutes for this test const org = await Org.create({ aliasOrUsername: getUsername() }); const connection = org.getConnection(); - const project = await SfProject.resolve(session.project.dir); - // Find the published agent from the publish test (starts with "Test_Agent_") const publishedAgents = await Agent.listRemote(connection); - const publishedAgent = publishedAgents.find((agent) => agent.DeveloperName?.startsWith('Test_Agent_')); - + const publishedAgent = publishedAgents.find((a) => a.DeveloperName?.startsWith('Test_Agent_')); expect(publishedAgent).to.not.be.undefined; expect(publishedAgent?.DeveloperName).to.be.a('string'); - // Query the Bot object to get the Id - const botResult = await connection.singleRecordQuery<{ Id: string }>( - `SELECT ID FROM BotDefinition WHERE DeveloperName = '${publishedAgent!.DeveloperName}'` - ); - - expect(botResult).to.not.be.undefined; - expect(botResult.Id).to.be.a('string').and.not.be.empty; - - // Initialize the published agent using its Bot Id - const agent = await Agent.init({ - connection, - project, - apiNameOrId: botResult.Id, + // Activate published agent before previewing + execCmd(`agent activate --api-name ${publishedAgent!.DeveloperName} --target-org ${getUsername()} --json`, { + ensureExitCode: 0, + cwd: session.project.dir, }); - // gotta activate published agents before previewing - await agent.activate(); - - // Start session - const previewSession = await agent.preview.start(); - expect(previewSession.sessionId).to.be.a('string'); - - const response = await agent.preview.send('What can you help me with?'); - expect(response.messages).to.be.an('array').with.length.greaterThan(0); + const targetOrg = getUsername(); + + const startResult = execCmd( + `agent preview start --api-name ${publishedAgent!.DeveloperName} --target-org ${targetOrg} --json` + ).jsonOutput?.result; + expect(startResult?.sessionId).to.be.a('string'); + const sessionId = startResult!.sessionId; + + const sendResult = execCmd( + `agent preview send --session-id ${sessionId} --api-name ${ + publishedAgent!.DeveloperName + } --utterance "What can you help me with?" --target-org ${targetOrg} --json` + ).jsonOutput?.result; + expect(sendResult?.messages).to.be.an('array').with.length.greaterThan(0); + + const endResult = execCmd( + `agent preview end --session-id ${sessionId} --api-name ${ + publishedAgent!.DeveloperName + } --target-org ${targetOrg} --json` + ).jsonOutput?.result; + expect(endResult?.sessionId).to.equal(sessionId); + expect(endResult?.tracesPath).to.be.a('string'); }); }); }); diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts new file mode 100644 index 00000000..a1a52e53 --- /dev/null +++ b/test/previewSessionStore.test.ts @@ -0,0 +1,254 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect } from 'chai'; +import { SfError, SfProject } from '@salesforce/core'; +import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; +import { + createCache, + getCachedSessionIds, + getCurrentSessionId, + listCachedSessions, + removeCache, + validatePreviewSession, +} from '../src/previewSessionStore.js'; + +function makeMockProject(getPath: () => string): SfProject { + return { getPath } as SfProject; +} + +function makeMockAgent(projectDir: string, agentId: string): ScriptAgent | ProductionAgent { + let sessionId: string | undefined; + return { + setSessionId(id: string) { + sessionId = id; + }, + getAgentIdForStorage(): string { + return agentId; + }, + async getHistoryDir(): Promise { + if (!sessionId) throw new Error('sessionId not set'); + const dir = join(projectDir, '.sfdx', 'agents', agentId, 'sessions', sessionId); + const { mkdir } = await import('node:fs/promises'); + await mkdir(dir, { recursive: true }); + return dir; + }, + } as ScriptAgent | ProductionAgent; +} + +describe('previewSessionStore', () => { + let projectPath: string; + + beforeEach(() => { + projectPath = mkdtempSync(join(tmpdir(), 'preview-session-store-')); + }); + + afterEach(() => { + rmSync(projectPath, { recursive: true, force: true }); + }); + + describe('createCache', () => { + it('saves session and validates with same agent', async () => { + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-1'); + await createCache(agent); + agent.setSessionId('sess-1'); + await validatePreviewSession(agent); + }); + + it('allows multiple sessions for same agent', async () => { + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-a'); + await createCache(agent); + agent.setSessionId('sess-b'); + await createCache(agent); + agent.setSessionId('sess-a'); + await validatePreviewSession(agent); + agent.setSessionId('sess-b'); + await validatePreviewSession(agent); + }); + }); + + describe('validatePreviewSession', () => { + it('throws PreviewSessionNotFound when session file does not exist', async () => { + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('unknown-sess'); + try { + await validatePreviewSession(agent); + expect.fail('Expected validatePreviewSession to throw'); + } catch (e) { + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionNotFound'); + expect((e as SfError).message).to.include('No preview session found'); + } + }); + + it('throws PreviewSessionNotFound when session id is for different agent', async () => { + const agentA = makeMockAgent(projectPath, 'agent-a'); + const agentB = makeMockAgent(projectPath, 'agent-b'); + agentA.setSessionId('sess-1'); + await createCache(agentA); + agentB.setSessionId('sess-1'); + try { + await validatePreviewSession(agentB); + expect.fail('Expected validatePreviewSession to throw'); + } catch (e) { + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionNotFound'); + } + }); + + it('succeeds when session exists for this agent', async () => { + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-1'); + await createCache(agent); + agent.setSessionId('sess-1'); + await validatePreviewSession(agent); + }); + }); + + describe('getCachedSessionIds', () => { + it('returns empty when no sessions', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'agent-1'); + const ids = await getCachedSessionIds(project, agent); + expect(ids).to.deep.equal([]); + }); + + it('returns session ids that have session-meta.json', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-1'); + await createCache(agent); + agent.setSessionId('sess-2'); + await createCache(agent); + const ids = await getCachedSessionIds(project, agent); + expect(ids).to.have.members(['sess-1', 'sess-2']); + }); + + it('does not return session dirs without session-meta.json', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-1'); + await createCache(agent); + const { mkdir } = await import('node:fs/promises'); + await mkdir(join(projectPath, '.sfdx', 'agents', 'agent-1', 'sessions', 'other-dir'), { + recursive: true, + }); + const ids = await getCachedSessionIds(project, agent); + expect(ids).to.deep.equal(['sess-1']); + }); + }); + + describe('removeCache', () => { + it('removes session from cache so getCachedSessionIds no longer includes it', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-1'); + await createCache(agent); + agent.setSessionId('sess-2'); + await createCache(agent); + let ids = await getCachedSessionIds(project, agent); + expect(ids).to.have.members(['sess-1', 'sess-2']); + agent.setSessionId('sess-1'); + await removeCache(agent); + ids = await getCachedSessionIds(project, agent); + expect(ids).to.deep.equal(['sess-2']); + }); + + it('after removing one of two sessions, getCurrentSessionId returns the remaining session', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-a'); + await createCache(agent); + agent.setSessionId('sess-b'); + await createCache(agent); + expect(await getCurrentSessionId(project, agent)).to.be.undefined; + agent.setSessionId('sess-a'); + await removeCache(agent); + expect(await getCurrentSessionId(project, agent)).to.equal('sess-b'); + }); + }); + + describe('listCachedSessions', () => { + it('returns empty when no cached sessions', async () => { + const project = makeMockProject(() => projectPath); + const list = await listCachedSessions(project); + expect(list).to.deep.equal([]); + }); + + it('returns agent ids and session ids for all cached sessions', async () => { + const project = makeMockProject(() => projectPath); + const agent1 = makeMockAgent(projectPath, 'bundle-a'); + agent1.setSessionId('s1'); + await createCache(agent1); + agent1.setSessionId('s2'); + await createCache(agent1); + const agent2 = makeMockAgent(projectPath, 'bundle-b'); + agent2.setSessionId('s3'); + await createCache(agent2); + const list = await listCachedSessions(project); + expect(list).to.have.lengthOf(2); + const byAgent = Object.fromEntries(list.map((e) => [e.agentId, e.sessionIds])); + expect(byAgent['bundle-a']).to.have.members(['s1', 's2']); + expect(byAgent['bundle-b']).to.deep.equal(['s3']); + }); + + it('returns displayName from session-meta when createCache was called with displayName', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'some-id'); + agent.setSessionId('s1'); + await createCache(agent, { displayName: 'My_Production_Agent' }); + const list = await listCachedSessions(project); + expect(list).to.have.lengthOf(1); + expect(list[0].agentId).to.equal('some-id'); + expect(list[0].displayName).to.equal('My_Production_Agent'); + expect(list[0].sessionIds).to.deep.equal(['s1']); + }); + }); + + describe('getCurrentSessionId', () => { + it('returns undefined when no sessions', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'agent-1'); + const id = await getCurrentSessionId(project, agent); + expect(id).to.be.undefined; + }); + + it('returns session id when exactly one session', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-1'); + await createCache(agent); + const id = await getCurrentSessionId(project, agent); + expect(id).to.equal('sess-1'); + }); + + it('returns undefined when multiple sessions', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-a'); + await createCache(agent); + agent.setSessionId('sess-b'); + await createCache(agent); + const id = await getCurrentSessionId(project, agent); + expect(id).to.be.undefined; + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8d68f93a..65e661dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1719,10 +1719,10 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@salesforce/agents@^0.22.1": - version "0.22.2" - resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.22.2.tgz#b9c6612040938c37cfa85f250cb6ee5a0a055135" - integrity sha512-TF/hvrM2wv3rSLWkBbLCjBGax6y5EaO5t6cevM+i/VMX140M+ls63O/8AaoXAwjNo3udorNUhm0wbs1HE3QfRw== +"@salesforce/agents@^0.22.4": + version "0.22.4" + resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.22.4.tgz#a9fe2beb94bd12e1f941bb22fc3c00ed24888662" + integrity sha512-Oe5LApwpEFm+VeOtatQPvyfcJlYIP6ixpJo/CbAIX0M9TI9UP7E/oLmQfkxylaiqYQzNtK+B21x7+Lwr6cPgxg== dependencies: "@salesforce/core" "^8.24.0" "@salesforce/kit" "^3.2.4"