From c06d6386b8840a757147713ca518cf174ecf9523 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 5 Feb 2026 15:03:19 -0700 Subject: [PATCH 01/13] fix: add agent preview start,send,end commands --- messages/agent.preview.end.md | 29 +++++++++++ messages/agent.preview.send.md | 29 +++++++++++ messages/agent.preview.start.md | 33 +++++++++++++ schemas/agent-preview-end.json | 19 +++++++ schemas/agent-preview-send.json | 23 +++++++++ schemas/agent-preview-start.json | 16 ++++++ src/commands/agent/preview/end.ts | 76 ++++++++++++++++++++++++++++ src/commands/agent/preview/send.ts | 75 ++++++++++++++++++++++++++++ src/commands/agent/preview/start.ts | 77 +++++++++++++++++++++++++++++ 9 files changed, 377 insertions(+) create mode 100644 messages/agent.preview.end.md create mode 100644 messages/agent.preview.send.md create mode 100644 messages/agent.preview.start.md create mode 100644 schemas/agent-preview-end.json create mode 100644 schemas/agent-preview-send.json create mode 100644 schemas/agent-preview-start.json create mode 100644 src/commands/agent/preview/end.ts create mode 100644 src/commands/agent/preview/send.ts create mode 100644 src/commands/agent/preview/start.ts diff --git a/messages/agent.preview.end.md b/messages/agent.preview.end.md new file mode 100644 index 00000000..9435ea38 --- /dev/null +++ b/messages/agent.preview.end.md @@ -0,0 +1,29 @@ +# 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 (.sfdx/agents/). 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" (required). + +# flags.api-name.summary + +API name or ID of the published agent. + +# flags.authoring-bundle.summary + +API name of the authoring bundle (Agent Script). + +# output.tracesPath + +Session traces: %s + +# examples + +- End a preview session: + + <%= 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..b75bc787 --- /dev/null +++ b/messages/agent.preview.send.md @@ -0,0 +1,29 @@ +# 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" (required). + +# 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). + +# examples + +- Send a message to a preview session: + + <%= config.bin %> <%= command.id %> --session-id --utterance "What can you help me with?" --target-org my-dev-org diff --git a/messages/agent.preview.start.md b/messages/agent.preview.start.md new file mode 100644 index 00000000..e7f6a856 --- /dev/null +++ b/messages/agent.preview.start.md @@ -0,0 +1,33 @@ +# 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 a published agent in live mode: + + <%= config.bin %> <%= command.id %> --api-name My_Published_Agent --use-live-actions --target-org my-dev-org diff --git a/schemas/agent-preview-end.json b/schemas/agent-preview-end.json new file mode 100644 index 00000000..96827c55 --- /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", + "additionalProperties": false, + "properties": { + "sessionId": { + "type": "string" + }, + "tracesPath": { + "type": "string" + } + }, + "required": ["sessionId", "tracesPath"] + } + } +} diff --git a/schemas/agent-preview-send.json b/schemas/agent-preview-send.json new file mode 100644 index 00000000..baa4db99 --- /dev/null +++ b/schemas/agent-preview-send.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentPreviewSendResult", + "definitions": { + "AgentPreviewSendResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "role": { "type": "string" } + } + } + } + }, + "required": ["messages"] + } + } +} diff --git a/schemas/agent-preview-start.json b/schemas/agent-preview-start.json new file mode 100644 index 00000000..b50b7b8b --- /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", + "additionalProperties": false, + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": ["sessionId"] + } + } +} diff --git a/src/commands/agent/preview/end.ts b/src/commands/agent/preview/end.ts new file mode 100644 index 00000000..b17302e4 --- /dev/null +++ b/src/commands/agent/preview/end.ts @@ -0,0 +1,76 @@ +/* + * 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 { join } from 'node:path'; +import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; +import { Messages } from '@salesforce/core'; +import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; + +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: true, + }), + '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 sessionId = flags['session-id']; + const projectPath = this.project!.getPath(); + + 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']! }); + + if (agent instanceof ScriptAgent) { + await agent.preview.end(); + } else if (agent instanceof ProductionAgent) { + await agent.preview.end('UserRequest'); + } + + const tracesPath = join(projectPath, '.sfdx', 'agents', agent.getAgentIdForStorage(), 'sessions', sessionId); + 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..4836897d --- /dev/null +++ b/src/commands/agent/preview/send.ts @@ -0,0 +1,75 @@ +/* + * 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 } from '@salesforce/core'; +import { Agent, ScriptAgent } from '@salesforce/agents'; + +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: true, + }), + 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']! }); + if (agent instanceof ScriptAgent) { + agent.preview.setMockMode('Mock'); + } + + agent.setSessionId(flags['session-id']); + + 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/start.ts b/src/commands/agent/preview/start.ts new file mode 100644 index 00000000..2c3fbd9b --- /dev/null +++ b/src/commands/agent/preview/start.ts @@ -0,0 +1,77 @@ +/* + * 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'; + +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 result: AgentPreviewStartResult = { sessionId: session.sessionId }; + this.log(messages.getMessage('output.sessionId', [session.sessionId])); + return result; + } +} From 4af0cf34f3a3169f15b53f1203846ad5a27b05a5 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 5 Feb 2026 15:04:03 -0700 Subject: [PATCH 02/13] chore: snapshots and schemas --- command-snapshot.json | 33 ++++++++++++++++++++++++++++++++ schemas/agent-preview-end.json | 4 ++-- schemas/agent-preview-send.json | 15 ++++++++++----- schemas/agent-preview-start.json | 4 ++-- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/command-snapshot.json b/command-snapshot.json index bc0fe751..6ac3ec55 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -92,6 +92,39 @@ ], "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: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/schemas/agent-preview-end.json b/schemas/agent-preview-end.json index 96827c55..9ce8737d 100644 --- a/schemas/agent-preview-end.json +++ b/schemas/agent-preview-end.json @@ -4,7 +4,6 @@ "definitions": { "AgentPreviewEndResult": { "type": "object", - "additionalProperties": false, "properties": { "sessionId": { "type": "string" @@ -13,7 +12,8 @@ "type": "string" } }, - "required": ["sessionId", "tracesPath"] + "required": ["sessionId", "tracesPath"], + "additionalProperties": false } } } diff --git a/schemas/agent-preview-send.json b/schemas/agent-preview-send.json index baa4db99..ad6d5d9a 100644 --- a/schemas/agent-preview-send.json +++ b/schemas/agent-preview-send.json @@ -4,20 +4,25 @@ "definitions": { "AgentPreviewSendResult": { "type": "object", - "additionalProperties": false, "properties": { "messages": { "type": "array", "items": { "type": "object", "properties": { - "message": { "type": "string" }, - "role": { "type": "string" } - } + "message": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "additionalProperties": false } } }, - "required": ["messages"] + "required": ["messages"], + "additionalProperties": false } } } diff --git a/schemas/agent-preview-start.json b/schemas/agent-preview-start.json index b50b7b8b..f608d52f 100644 --- a/schemas/agent-preview-start.json +++ b/schemas/agent-preview-start.json @@ -4,13 +4,13 @@ "definitions": { "AgentPreviewStartResult": { "type": "object", - "additionalProperties": false, "properties": { "sessionId": { "type": "string" } }, - "required": ["sessionId"] + "required": ["sessionId"], + "additionalProperties": false } } } From b6b86ccf82f62bb9243f9d17315229d6df072311 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 5 Feb 2026 15:42:51 -0700 Subject: [PATCH 03/13] chore: add cache to valid session/agent combo for send/end --- src/commands/agent/preview/end.ts | 7 +++ src/commands/agent/preview/send.ts | 7 +++ src/commands/agent/preview/start.ts | 8 +++ src/previewSessionStore.ts | 95 +++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/previewSessionStore.ts diff --git a/src/commands/agent/preview/end.ts b/src/commands/agent/preview/end.ts index b17302e4..9d73b134 100644 --- a/src/commands/agent/preview/end.ts +++ b/src/commands/agent/preview/end.ts @@ -18,6 +18,7 @@ import { join } from 'node:path'; import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; +import { validatePreviewSession } from '../../../previewSessionStore.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.end'); @@ -57,6 +58,12 @@ export default class AgentPreviewEnd extends SfCommand { const sessionId = flags['session-id']; const projectPath = this.project!.getPath(); + await validatePreviewSession(projectPath, sessionId, { + apiNameOrId: flags['api-name'], + aabName: flags['authoring-bundle'], + orgUsername: flags['target-org'].getUsername() ?? '', + }); + 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'] }) diff --git a/src/commands/agent/preview/send.ts b/src/commands/agent/preview/send.ts index 4836897d..87df0246 100644 --- a/src/commands/agent/preview/send.ts +++ b/src/commands/agent/preview/send.ts @@ -17,6 +17,7 @@ import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; import { Agent, ScriptAgent } from '@salesforce/agents'; +import { validatePreviewSession } from '../../../previewSessionStore.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.send'); @@ -58,6 +59,12 @@ export default class AgentPreviewSend extends SfCommand public async run(): Promise { const { flags } = await this.parse(AgentPreviewSend); + await validatePreviewSession(this.project!.getPath(), flags['session-id'], { + apiNameOrId: flags['api-name'], + aabName: flags['authoring-bundle'], + orgUsername: flags['target-org'].getUsername() ?? '', + }); + 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'] }) diff --git a/src/commands/agent/preview/start.ts b/src/commands/agent/preview/start.ts index 2c3fbd9b..2cb9ffc1 100644 --- a/src/commands/agent/preview/start.ts +++ b/src/commands/agent/preview/start.ts @@ -17,6 +17,7 @@ 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'); @@ -70,6 +71,13 @@ export default class AgentPreviewStart extends SfCommand>; + +function getStorePath(projectPath: string): string { + return join(projectPath, CACHE_DIR, AGENTS_DIR, SESSIONS_FILE); +} + +/** + * Save a preview session so send/end can validate that the session was started with the same agent and org. + */ +export async function createCache(projectPath: string, entry: PreviewSessionEntry): Promise { + const dir = join(projectPath, CACHE_DIR, AGENTS_DIR); + await mkdir(dir, { recursive: true }); + const path = getStorePath(projectPath); + let store: SessionsStore = {}; + try { + const data = await readFile(path, 'utf-8'); + store = JSON.parse(data) as SessionsStore; + } catch { + // file missing or invalid + } + const { sessionId, ...rest } = entry; + store[sessionId] = rest; + await writeFile(path, JSON.stringify(store, null, 2), 'utf-8'); +} + +/** + * Validate that the given session was started with the specified agent and org. + * Throws SfError if the session is unknown or does not match. + */ +export async function validatePreviewSession( + projectPath: string, + sessionId: string, + agentAndOrg: { apiNameOrId?: string; aabName?: string; orgUsername: string } +): Promise { + const data = await readFile(getStorePath(projectPath), 'utf-8'); + const store = JSON.parse(data) as SessionsStore; + const entry = store[sessionId]; + if (!entry) { + throw new SfError( + `No preview session found for session ID "${sessionId}". Run "sf agent preview start" first.`, + 'PreviewSessionNotFound' + ); + } + if (entry.orgUsername !== agentAndOrg.orgUsername) { + throw new SfError( + `Session ${sessionId} was started with a different target org. Use --target-org ${entry.orgUsername} for this session.`, + 'PreviewSessionOrgMismatch' + ); + } + const entryAgent = entry.aabName ? `--authoring-bundle ${entry.aabName}` : `--api-name ${entry.apiNameOrId ?? ''}`; + if (entry.aabName) { + if (agentAndOrg.aabName !== entry.aabName) { + throw new SfError( + `Session ${sessionId} was started with ${entryAgent}. Use the same agent for send/end.`, + 'PreviewSessionAgentMismatch' + ); + } + } else if (agentAndOrg.apiNameOrId !== entry.apiNameOrId) { + throw new SfError( + `Session ${sessionId} was started with ${entryAgent}. Use the same agent for send/end.`, + 'PreviewSessionAgentMismatch' + ); + } +} From 08278c29fd0677fe46a3948cf4ef7c14e6e7f46e Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 5 Feb 2026 16:12:12 -0700 Subject: [PATCH 04/13] test: add UT/NUT --- src/previewSessionStore.ts | 10 +- test/nuts/z3.agent.preview.nut.ts | 154 +++++++++----------- test/previewSessionStore.test.ts | 235 ++++++++++++++++++++++++++++++ 3 files changed, 316 insertions(+), 83 deletions(-) create mode 100644 test/previewSessionStore.test.ts diff --git a/src/previewSessionStore.ts b/src/previewSessionStore.ts index 02d6e150..8c5e4ed9 100644 --- a/src/previewSessionStore.ts +++ b/src/previewSessionStore.ts @@ -63,7 +63,15 @@ export async function validatePreviewSession( sessionId: string, agentAndOrg: { apiNameOrId?: string; aabName?: string; orgUsername: string } ): Promise { - const data = await readFile(getStorePath(projectPath), 'utf-8'); + let data: string; + try { + data = await readFile(getStorePath(projectPath), 'utf-8'); + } catch { + throw new SfError( + `No preview session found for session ID "${sessionId}". Run "sf agent preview start" first.`, + 'PreviewSessionNotFound' + ); + } const store = JSON.parse(data) as SessionsStore; const entry = store[sessionId]; if (!entry) { 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..d33bf1a0 --- /dev/null +++ b/test/previewSessionStore.test.ts @@ -0,0 +1,235 @@ +/* + * 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 } from '@salesforce/core'; +import { createCache, validatePreviewSession } from '../src/previewSessionStore.js'; + +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 with authoring-bundle', async () => { + await createCache(projectPath, { + sessionId: 'sess-1', + orgUsername: 'user@org.com', + aabName: 'My_Bundle', + }); + await validatePreviewSession(projectPath, 'sess-1', { + aabName: 'My_Bundle', + orgUsername: 'user@org.com', + }); + }); + + it('saves session with api-name', async () => { + await createCache(projectPath, { + sessionId: 'sess-2', + orgUsername: 'user@org.com', + apiNameOrId: 'My_Published_Agent', + }); + await validatePreviewSession(projectPath, 'sess-2', { + apiNameOrId: 'My_Published_Agent', + orgUsername: 'user@org.com', + }); + }); + + it('allows multiple sessions in same store', async () => { + await createCache(projectPath, { + sessionId: 'sess-a', + orgUsername: 'user@org.com', + aabName: 'Bundle_A', + }); + await createCache(projectPath, { + sessionId: 'sess-b', + orgUsername: 'user@org.com', + apiNameOrId: 'Agent_B', + }); + await validatePreviewSession(projectPath, 'sess-a', { + aabName: 'Bundle_A', + orgUsername: 'user@org.com', + }); + await validatePreviewSession(projectPath, 'sess-b', { + apiNameOrId: 'Agent_B', + orgUsername: 'user@org.com', + }); + }); + }); + + describe('validatePreviewSession', () => { + it('throws PreviewSessionNotFound when store file does not exist', async () => { + try { + await validatePreviewSession(projectPath, 'unknown-sess', { + aabName: 'My_Bundle', + orgUsername: 'user@org.com', + }); + 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'); + expect((e as SfError).message).to.include('unknown-sess'); + } + }); + + it('throws PreviewSessionNotFound when session id not in store', async () => { + await createCache(projectPath, { + sessionId: 'sess-1', + orgUsername: 'user@org.com', + aabName: 'My_Bundle', + }); + try { + await validatePreviewSession(projectPath, 'other-sess', { + aabName: 'My_Bundle', + orgUsername: 'user@org.com', + }); + expect.fail('Expected validatePreviewSession to throw'); + } catch (e) { + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionNotFound'); + } + }); + + it('throws PreviewSessionOrgMismatch when org differs', async () => { + await createCache(projectPath, { + sessionId: 'sess-1', + orgUsername: 'user@org-a.com', + aabName: 'My_Bundle', + }); + try { + await validatePreviewSession(projectPath, 'sess-1', { + aabName: 'My_Bundle', + orgUsername: 'user@org-b.com', + }); + expect.fail('Expected validatePreviewSession to throw'); + } catch (e) { + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionOrgMismatch'); + expect((e as SfError).message).to.include('different target org'); + expect((e as SfError).message).to.include('user@org-a.com'); + } + }); + + it('throws PreviewSessionAgentMismatch when authoring-bundle differs', async () => { + await createCache(projectPath, { + sessionId: 'sess-1', + orgUsername: 'user@org.com', + aabName: 'Bundle_A', + }); + try { + await validatePreviewSession(projectPath, 'sess-1', { + aabName: 'Bundle_B', + orgUsername: 'user@org.com', + }); + expect.fail('Expected validatePreviewSession to throw'); + } catch (e) { + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionAgentMismatch'); + expect((e as SfError).message).to.include('Session sess-1 was started with'); + expect((e as SfError).message).to.include('Bundle_A'); + } + }); + + it('throws PreviewSessionAgentMismatch when api-name differs', async () => { + await createCache(projectPath, { + sessionId: 'sess-1', + orgUsername: 'user@org.com', + apiNameOrId: 'Agent_A', + }); + try { + await validatePreviewSession(projectPath, 'sess-1', { + apiNameOrId: 'Agent_B', + orgUsername: 'user@org.com', + }); + expect.fail('Expected validatePreviewSession to throw'); + } catch (e) { + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionAgentMismatch'); + expect((e as SfError).message).to.include('Agent_A'); + } + }); + + it('throws when session was started with authoring-bundle but send uses api-name', async () => { + await createCache(projectPath, { + sessionId: 'sess-1', + orgUsername: 'user@org.com', + aabName: 'My_Bundle', + }); + try { + await validatePreviewSession(projectPath, 'sess-1', { + apiNameOrId: 'Some_Agent', + orgUsername: 'user@org.com', + }); + expect.fail('Expected validatePreviewSession to throw'); + } catch (e) { + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionAgentMismatch'); + } + }); + + it('throws when session was started with api-name but send uses authoring-bundle', async () => { + await createCache(projectPath, { + sessionId: 'sess-1', + orgUsername: 'user@org.com', + apiNameOrId: 'My_Agent', + }); + try { + await validatePreviewSession(projectPath, 'sess-1', { + aabName: 'Some_Bundle', + orgUsername: 'user@org.com', + }); + expect.fail('Expected validatePreviewSession to throw'); + } catch (e) { + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionAgentMismatch'); + } + }); + + it('succeeds when agent and org match (authoring-bundle)', async () => { + await createCache(projectPath, { + sessionId: 'sess-1', + orgUsername: 'user@org.com', + aabName: 'My_Bundle', + }); + await validatePreviewSession(projectPath, 'sess-1', { + aabName: 'My_Bundle', + orgUsername: 'user@org.com', + }); + }); + + it('succeeds when agent and org match (api-name)', async () => { + await createCache(projectPath, { + sessionId: 'sess-1', + orgUsername: 'user@org.com', + apiNameOrId: 'My_Agent', + }); + await validatePreviewSession(projectPath, 'sess-1', { + apiNameOrId: 'My_Agent', + orgUsername: 'user@org.com', + }); + }); + }); +}); From d4d876e7e4fa49d0f83be53a4dae2dabbb5a5563 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 5 Feb 2026 16:47:58 -0700 Subject: [PATCH 05/13] chore: refactor cache, src only --- src/commands/agent/preview/end.ts | 15 ++--- src/commands/agent/preview/send.ts | 7 +-- src/commands/agent/preview/start.ts | 8 +-- src/previewSessionStore.ts | 87 ++++++----------------------- 4 files changed, 24 insertions(+), 93 deletions(-) diff --git a/src/commands/agent/preview/end.ts b/src/commands/agent/preview/end.ts index 9d73b134..4a047cfc 100644 --- a/src/commands/agent/preview/end.ts +++ b/src/commands/agent/preview/end.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { join } from 'node:path'; import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; @@ -56,26 +55,22 @@ export default class AgentPreviewEnd extends SfCommand { public async run(): Promise { const { flags } = await this.parse(AgentPreviewEnd); const sessionId = flags['session-id']; - const projectPath = this.project!.getPath(); - - await validatePreviewSession(projectPath, sessionId, { - apiNameOrId: flags['api-name'], - aabName: flags['authoring-bundle'], - orgUsername: flags['target-org'].getUsername() ?? '', - }); 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']! }); + agent.setSessionId(sessionId); + await validatePreviewSession(agent); + + const tracesPath = await (agent as unknown as { getHistoryDir(): Promise }).getHistoryDir(); + if (agent instanceof ScriptAgent) { await agent.preview.end(); } else if (agent instanceof ProductionAgent) { await agent.preview.end('UserRequest'); } - - const tracesPath = join(projectPath, '.sfdx', 'agents', agent.getAgentIdForStorage(), 'sessions', sessionId); 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 index 87df0246..e604d811 100644 --- a/src/commands/agent/preview/send.ts +++ b/src/commands/agent/preview/send.ts @@ -59,12 +59,6 @@ export default class AgentPreviewSend extends SfCommand public async run(): Promise { const { flags } = await this.parse(AgentPreviewSend); - await validatePreviewSession(this.project!.getPath(), flags['session-id'], { - apiNameOrId: flags['api-name'], - aabName: flags['authoring-bundle'], - orgUsername: flags['target-org'].getUsername() ?? '', - }); - 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'] }) @@ -74,6 +68,7 @@ export default class AgentPreviewSend extends SfCommand } agent.setSessionId(flags['session-id']); + await validatePreviewSession(agent); const response = await agent.preview.send(flags.utterance); this.log(response.messages[0].message); diff --git a/src/commands/agent/preview/start.ts b/src/commands/agent/preview/start.ts index 2cb9ffc1..36c4e2f0 100644 --- a/src/commands/agent/preview/start.ts +++ b/src/commands/agent/preview/start.ts @@ -70,13 +70,7 @@ export default class AgentPreviewStart extends SfCommand>; - -function getStorePath(projectPath: string): string { - return join(projectPath, CACHE_DIR, AGENTS_DIR, SESSIONS_FILE); -} +const SESSION_META_FILE = 'session-meta.json'; /** - * Save a preview session so send/end can validate that the session was started with the same agent and org. + * 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. */ -export async function createCache(projectPath: string, entry: PreviewSessionEntry): Promise { - const dir = join(projectPath, CACHE_DIR, AGENTS_DIR); - await mkdir(dir, { recursive: true }); - const path = getStorePath(projectPath); - let store: SessionsStore = {}; - try { - const data = await readFile(path, 'utf-8'); - store = JSON.parse(data) as SessionsStore; - } catch { - // file missing or invalid - } - const { sessionId, ...rest } = entry; - store[sessionId] = rest; - await writeFile(path, JSON.stringify(store, null, 2), 'utf-8'); +export async function createCache(agent: AgentInstance): Promise { + const historyDir = await agent.getHistoryDir(); + const path = join(historyDir, SESSION_META_FILE); + await writeFile(path, JSON.stringify({}), 'utf-8'); } /** - * Validate that the given session was started with the specified agent and org. - * Throws SfError if the session is unknown or does not match. + * 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( - projectPath: string, - sessionId: string, - agentAndOrg: { apiNameOrId?: string; aabName?: string; orgUsername: string } -): Promise { - let data: string; +export async function validatePreviewSession(agent: AgentInstance): Promise { + const historyDir = await agent.getHistoryDir(); + const path = join(historyDir, SESSION_META_FILE); try { - data = await readFile(getStorePath(projectPath), 'utf-8'); + await readFile(path, 'utf-8'); } catch { throw new SfError( - `No preview session found for session ID "${sessionId}". Run "sf agent preview start" first.`, + 'No preview session found for this session ID. Run "sf agent preview start" first.', 'PreviewSessionNotFound' ); } - const store = JSON.parse(data) as SessionsStore; - const entry = store[sessionId]; - if (!entry) { - throw new SfError( - `No preview session found for session ID "${sessionId}". Run "sf agent preview start" first.`, - 'PreviewSessionNotFound' - ); - } - if (entry.orgUsername !== agentAndOrg.orgUsername) { - throw new SfError( - `Session ${sessionId} was started with a different target org. Use --target-org ${entry.orgUsername} for this session.`, - 'PreviewSessionOrgMismatch' - ); - } - const entryAgent = entry.aabName ? `--authoring-bundle ${entry.aabName}` : `--api-name ${entry.apiNameOrId ?? ''}`; - if (entry.aabName) { - if (agentAndOrg.aabName !== entry.aabName) { - throw new SfError( - `Session ${sessionId} was started with ${entryAgent}. Use the same agent for send/end.`, - 'PreviewSessionAgentMismatch' - ); - } - } else if (agentAndOrg.apiNameOrId !== entry.apiNameOrId) { - throw new SfError( - `Session ${sessionId} was started with ${entryAgent}. Use the same agent for send/end.`, - 'PreviewSessionAgentMismatch' - ); - } } From b1dbad7b1efb1aeffe4574ab0ad41c43b4268f71 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 5 Feb 2026 16:51:17 -0700 Subject: [PATCH 06/13] test: fix UT --- test/previewSessionStore.test.ts | 224 +++++++------------------------ 1 file changed, 50 insertions(+), 174 deletions(-) diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts index d33bf1a0..bd594833 100644 --- a/test/previewSessionStore.test.ts +++ b/test/previewSessionStore.test.ts @@ -21,6 +21,23 @@ import { expect } from 'chai'; import { SfError } from '@salesforce/core'; import { createCache, validatePreviewSession } from '../src/previewSessionStore.js'; +function makeMockAgent(baseDir: string, agentId: string) { + let sessionId: string | undefined; + const agent = { + setSessionId(id: string) { + sessionId = id; + }, + async getHistoryDir(): Promise { + if (!sessionId) throw new Error('sessionId not set'); + const dir = join(baseDir, 'agents', agentId, 'sessions', sessionId); + const { mkdir } = await import('node:fs/promises'); + await mkdir(dir, { recursive: true }); + return dir; + }, + }; + return agent; +} + describe('previewSessionStore', () => { let projectPath: string; @@ -33,79 +50,49 @@ describe('previewSessionStore', () => { }); describe('createCache', () => { - it('saves session with authoring-bundle', async () => { - await createCache(projectPath, { - sessionId: 'sess-1', - orgUsername: 'user@org.com', - aabName: 'My_Bundle', - }); - await validatePreviewSession(projectPath, 'sess-1', { - aabName: 'My_Bundle', - orgUsername: 'user@org.com', - }); - }); - - it('saves session with api-name', async () => { - await createCache(projectPath, { - sessionId: 'sess-2', - orgUsername: 'user@org.com', - apiNameOrId: 'My_Published_Agent', - }); - await validatePreviewSession(projectPath, 'sess-2', { - apiNameOrId: 'My_Published_Agent', - orgUsername: 'user@org.com', - }); + it('saves session and validates with same agent', async () => { + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-1'); + await createCache(agent as never); + agent.setSessionId('sess-1'); + await validatePreviewSession(agent as never); }); - it('allows multiple sessions in same store', async () => { - await createCache(projectPath, { - sessionId: 'sess-a', - orgUsername: 'user@org.com', - aabName: 'Bundle_A', - }); - await createCache(projectPath, { - sessionId: 'sess-b', - orgUsername: 'user@org.com', - apiNameOrId: 'Agent_B', - }); - await validatePreviewSession(projectPath, 'sess-a', { - aabName: 'Bundle_A', - orgUsername: 'user@org.com', - }); - await validatePreviewSession(projectPath, 'sess-b', { - apiNameOrId: 'Agent_B', - orgUsername: 'user@org.com', - }); + it('allows multiple sessions for same agent', async () => { + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-a'); + await createCache(agent as never); + agent.setSessionId('sess-b'); + await createCache(agent as never); + agent.setSessionId('sess-a'); + await validatePreviewSession(agent as never); + agent.setSessionId('sess-b'); + await validatePreviewSession(agent as never); }); }); describe('validatePreviewSession', () => { - it('throws PreviewSessionNotFound when store file does not exist', async () => { + it('throws PreviewSessionNotFound when session file does not exist', async () => { + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('unknown-sess'); try { - await validatePreviewSession(projectPath, 'unknown-sess', { - aabName: 'My_Bundle', - orgUsername: 'user@org.com', - }); + await validatePreviewSession(agent as never); 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'); - expect((e as SfError).message).to.include('unknown-sess'); } }); - it('throws PreviewSessionNotFound when session id not in store', async () => { - await createCache(projectPath, { - sessionId: 'sess-1', - orgUsername: 'user@org.com', - aabName: 'My_Bundle', - }); + it('throws PreviewSessionNotFound when session id is for different agent', async () => { + const agentA = makeMockAgent(projectPath, 'agent-a'); + const agentB = makeMockAgent(projectPath, 'agent-b'); + (agentA as { setSessionId: (id: string) => void }).setSessionId('sess-1'); + await createCache(agentA as never); + (agentB as { setSessionId: (id: string) => void }).setSessionId('sess-1'); try { - await validatePreviewSession(projectPath, 'other-sess', { - aabName: 'My_Bundle', - orgUsername: 'user@org.com', - }); + await validatePreviewSession(agentB as never); expect.fail('Expected validatePreviewSession to throw'); } catch (e) { expect(e).to.be.instanceOf(SfError); @@ -113,123 +100,12 @@ describe('previewSessionStore', () => { } }); - it('throws PreviewSessionOrgMismatch when org differs', async () => { - await createCache(projectPath, { - sessionId: 'sess-1', - orgUsername: 'user@org-a.com', - aabName: 'My_Bundle', - }); - try { - await validatePreviewSession(projectPath, 'sess-1', { - aabName: 'My_Bundle', - orgUsername: 'user@org-b.com', - }); - expect.fail('Expected validatePreviewSession to throw'); - } catch (e) { - expect(e).to.be.instanceOf(SfError); - expect((e as SfError).name).to.equal('PreviewSessionOrgMismatch'); - expect((e as SfError).message).to.include('different target org'); - expect((e as SfError).message).to.include('user@org-a.com'); - } - }); - - it('throws PreviewSessionAgentMismatch when authoring-bundle differs', async () => { - await createCache(projectPath, { - sessionId: 'sess-1', - orgUsername: 'user@org.com', - aabName: 'Bundle_A', - }); - try { - await validatePreviewSession(projectPath, 'sess-1', { - aabName: 'Bundle_B', - orgUsername: 'user@org.com', - }); - expect.fail('Expected validatePreviewSession to throw'); - } catch (e) { - expect(e).to.be.instanceOf(SfError); - expect((e as SfError).name).to.equal('PreviewSessionAgentMismatch'); - expect((e as SfError).message).to.include('Session sess-1 was started with'); - expect((e as SfError).message).to.include('Bundle_A'); - } - }); - - it('throws PreviewSessionAgentMismatch when api-name differs', async () => { - await createCache(projectPath, { - sessionId: 'sess-1', - orgUsername: 'user@org.com', - apiNameOrId: 'Agent_A', - }); - try { - await validatePreviewSession(projectPath, 'sess-1', { - apiNameOrId: 'Agent_B', - orgUsername: 'user@org.com', - }); - expect.fail('Expected validatePreviewSession to throw'); - } catch (e) { - expect(e).to.be.instanceOf(SfError); - expect((e as SfError).name).to.equal('PreviewSessionAgentMismatch'); - expect((e as SfError).message).to.include('Agent_A'); - } - }); - - it('throws when session was started with authoring-bundle but send uses api-name', async () => { - await createCache(projectPath, { - sessionId: 'sess-1', - orgUsername: 'user@org.com', - aabName: 'My_Bundle', - }); - try { - await validatePreviewSession(projectPath, 'sess-1', { - apiNameOrId: 'Some_Agent', - orgUsername: 'user@org.com', - }); - expect.fail('Expected validatePreviewSession to throw'); - } catch (e) { - expect(e).to.be.instanceOf(SfError); - expect((e as SfError).name).to.equal('PreviewSessionAgentMismatch'); - } - }); - - it('throws when session was started with api-name but send uses authoring-bundle', async () => { - await createCache(projectPath, { - sessionId: 'sess-1', - orgUsername: 'user@org.com', - apiNameOrId: 'My_Agent', - }); - try { - await validatePreviewSession(projectPath, 'sess-1', { - aabName: 'Some_Bundle', - orgUsername: 'user@org.com', - }); - expect.fail('Expected validatePreviewSession to throw'); - } catch (e) { - expect(e).to.be.instanceOf(SfError); - expect((e as SfError).name).to.equal('PreviewSessionAgentMismatch'); - } - }); - - it('succeeds when agent and org match (authoring-bundle)', async () => { - await createCache(projectPath, { - sessionId: 'sess-1', - orgUsername: 'user@org.com', - aabName: 'My_Bundle', - }); - await validatePreviewSession(projectPath, 'sess-1', { - aabName: 'My_Bundle', - orgUsername: 'user@org.com', - }); - }); - - it('succeeds when agent and org match (api-name)', async () => { - await createCache(projectPath, { - sessionId: 'sess-1', - orgUsername: 'user@org.com', - apiNameOrId: 'My_Agent', - }); - await validatePreviewSession(projectPath, 'sess-1', { - apiNameOrId: 'My_Agent', - orgUsername: 'user@org.com', - }); + it('succeeds when session exists for this agent', async () => { + const agent = makeMockAgent(projectPath, 'agent-1'); + agent.setSessionId('sess-1'); + await createCache(agent as never); + agent.setSessionId('sess-1'); + await validatePreviewSession(agent as never); }); }); }); From e4e2a308f527cd8dffe36f64eb63cd263913fb85 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 6 Feb 2026 10:39:15 -0700 Subject: [PATCH 07/13] chore: caching sessionid flag for send/end --- messages/agent.preview.end.md | 16 +++- messages/agent.preview.send.md | 14 +++- src/commands/agent/preview/end.ts | 23 ++++-- src/commands/agent/preview/send.ts | 28 +++++-- src/previewSessionStore.ts | 64 ++++++++++++++-- test/previewSessionStore.test.ts | 114 ++++++++++++++++++++++++----- 6 files changed, 215 insertions(+), 44 deletions(-) diff --git a/messages/agent.preview.end.md b/messages/agent.preview.end.md index 9435ea38..6e2b8a29 100644 --- a/messages/agent.preview.end.md +++ b/messages/agent.preview.end.md @@ -8,7 +8,7 @@ End an existing preview session and print the local path where session traces ar # flags.session-id.summary -Session ID from "agent preview start" (required). +Session ID from "agent preview start". Omit when the agent has exactly one active session. # flags.api-name.summary @@ -18,12 +18,24 @@ API name or ID of the published agent. 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 a preview session: +- End the single active preview session: + + <%= config.bin %> <%= command.id %> --target-org my-dev-org + +- 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 index b75bc787..a38c50c9 100644 --- a/messages/agent.preview.send.md +++ b/messages/agent.preview.send.md @@ -8,7 +8,7 @@ Send an utterance to an existing preview session and get the agent response. Use # flags.session-id.summary -Session ID from "agent preview start" (required). +Session ID from "agent preview start". Omit when the agent has exactly one active session. # flags.utterance.summary @@ -22,8 +22,20 @@ API name or ID of the published agent. 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 preview session: + <%= config.bin %> <%= command.id %> --utterance "What can you help me with?" --target-org my-dev-org + +- 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/src/commands/agent/preview/end.ts b/src/commands/agent/preview/end.ts index 4a047cfc..a4c78cab 100644 --- a/src/commands/agent/preview/end.ts +++ b/src/commands/agent/preview/end.ts @@ -15,9 +15,9 @@ */ import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; -import { Messages } from '@salesforce/core'; +import { Messages, SfError } from '@salesforce/core'; import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; -import { validatePreviewSession } from '../../../previewSessionStore.js'; +import { getCachedSessionIds, validatePreviewSession } from '../../../previewSessionStore.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.end'); @@ -39,7 +39,7 @@ export default class AgentPreviewEnd extends SfCommand { 'api-version': Flags.orgApiVersion(), 'session-id': Flags.string({ summary: messages.getMessage('flags.session-id.summary'), - required: true, + required: false, }), 'api-name': Flags.string({ summary: messages.getMessage('flags.api-name.summary'), @@ -54,17 +54,30 @@ export default class AgentPreviewEnd extends SfCommand { public async run(): Promise { const { flags } = await this.parse(AgentPreviewEnd); - const sessionId = flags['session-id']; 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 as unknown as { getHistoryDir(): Promise }).getHistoryDir(); + const tracesPath = await agent.getHistoryDir(); if (agent instanceof ScriptAgent) { await agent.preview.end(); diff --git a/src/commands/agent/preview/send.ts b/src/commands/agent/preview/send.ts index e604d811..a87dc959 100644 --- a/src/commands/agent/preview/send.ts +++ b/src/commands/agent/preview/send.ts @@ -15,9 +15,9 @@ */ import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; -import { Messages } from '@salesforce/core'; -import { Agent, ScriptAgent } from '@salesforce/agents'; -import { validatePreviewSession } from '../../../previewSessionStore.js'; +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'); @@ -38,7 +38,7 @@ export default class AgentPreviewSend extends SfCommand 'api-version': Flags.orgApiVersion(), 'session-id': Flags.string({ summary: messages.getMessage('flags.session-id.summary'), - required: true, + required: false, }), utterance: Flags.string({ summary: messages.getMessage('flags.utterance.summary'), @@ -60,14 +60,26 @@ export default class AgentPreviewSend extends SfCommand 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']! }); - if (agent instanceof ScriptAgent) { - agent.preview.setMockMode('Mock'); - } - agent.setSessionId(flags['session-id']); + 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); diff --git a/src/previewSessionStore.ts b/src/previewSessionStore.ts index 6651b035..8d78659f 100644 --- a/src/previewSessionStore.ts +++ b/src/previewSessionStore.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -import { readFile, writeFile } from 'node:fs/promises'; +import { readdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { SfError } from '@salesforce/core'; -import type { AgentInstance } from '@salesforce/agents'; +import type { SfProject } from '@salesforce/core'; +import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; const SESSION_META_FILE = 'session-meta.json'; @@ -25,10 +26,12 @@ const SESSION_META_FILE = 'session-meta.json'; * 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. */ -export async function createCache(agent: AgentInstance): Promise { +export async function createCache(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 path = join(historyDir, SESSION_META_FILE); - await writeFile(path, JSON.stringify({}), 'utf-8'); + 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 */ + await writeFile(metaPath, JSON.stringify({}), 'utf-8'); } /** @@ -36,11 +39,13 @@ export async function createCache(agent: AgentInstance): Promise { * 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: AgentInstance): Promise { +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 path = join(historyDir, SESSION_META_FILE); + 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(path, 'utf-8'); + await readFile(metaPath, 'utf-8'); } catch { throw new SfError( 'No preview session found for this session ID. Run "sf agent preview start" first.', @@ -48,3 +53,46 @@ export async function validatePreviewSession(agent: AgentInstance): Promise/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; +} diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts index bd594833..26b1bb27 100644 --- a/test/previewSessionStore.test.ts +++ b/test/previewSessionStore.test.ts @@ -18,24 +18,36 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect } from 'chai'; -import { SfError } from '@salesforce/core'; -import { createCache, validatePreviewSession } from '../src/previewSessionStore.js'; +import { SfError, SfProject } from '@salesforce/core'; +import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; +import { + createCache, + getCachedSessionIds, + getCurrentSessionId, + validatePreviewSession, +} from '../src/previewSessionStore.js'; -function makeMockAgent(baseDir: string, agentId: string) { +function makeMockProject(getPath: () => string): SfProject { + return { getPath } as SfProject; +} + +function makeMockAgent(projectDir: string, agentId: string): ScriptAgent | ProductionAgent { let sessionId: string | undefined; - const agent = { + return { setSessionId(id: string) { sessionId = id; }, + getAgentIdForStorage(): string { + return agentId; + }, async getHistoryDir(): Promise { if (!sessionId) throw new Error('sessionId not set'); - const dir = join(baseDir, 'agents', agentId, 'sessions', sessionId); + const dir = join(projectDir, '.sfdx', 'agents', agentId, 'sessions', sessionId); const { mkdir } = await import('node:fs/promises'); await mkdir(dir, { recursive: true }); return dir; }, - }; - return agent; + } as ScriptAgent | ProductionAgent; } describe('previewSessionStore', () => { @@ -53,21 +65,21 @@ describe('previewSessionStore', () => { it('saves session and validates with same agent', async () => { const agent = makeMockAgent(projectPath, 'agent-1'); agent.setSessionId('sess-1'); - await createCache(agent as never); + await createCache(agent); agent.setSessionId('sess-1'); - await validatePreviewSession(agent as never); + await validatePreviewSession(agent); }); it('allows multiple sessions for same agent', async () => { const agent = makeMockAgent(projectPath, 'agent-1'); agent.setSessionId('sess-a'); - await createCache(agent as never); + await createCache(agent); agent.setSessionId('sess-b'); - await createCache(agent as never); + await createCache(agent); agent.setSessionId('sess-a'); - await validatePreviewSession(agent as never); + await validatePreviewSession(agent); agent.setSessionId('sess-b'); - await validatePreviewSession(agent as never); + await validatePreviewSession(agent); }); }); @@ -76,7 +88,7 @@ describe('previewSessionStore', () => { const agent = makeMockAgent(projectPath, 'agent-1'); agent.setSessionId('unknown-sess'); try { - await validatePreviewSession(agent as never); + await validatePreviewSession(agent); expect.fail('Expected validatePreviewSession to throw'); } catch (e) { expect(e).to.be.instanceOf(SfError); @@ -88,11 +100,11 @@ describe('previewSessionStore', () => { it('throws PreviewSessionNotFound when session id is for different agent', async () => { const agentA = makeMockAgent(projectPath, 'agent-a'); const agentB = makeMockAgent(projectPath, 'agent-b'); - (agentA as { setSessionId: (id: string) => void }).setSessionId('sess-1'); - await createCache(agentA as never); - (agentB as { setSessionId: (id: string) => void }).setSessionId('sess-1'); + agentA.setSessionId('sess-1'); + await createCache(agentA); + agentB.setSessionId('sess-1'); try { - await validatePreviewSession(agentB as never); + await validatePreviewSession(agentB); expect.fail('Expected validatePreviewSession to throw'); } catch (e) { expect(e).to.be.instanceOf(SfError); @@ -103,9 +115,71 @@ describe('previewSessionStore', () => { it('succeeds when session exists for this agent', async () => { const agent = makeMockAgent(projectPath, 'agent-1'); agent.setSessionId('sess-1'); - await createCache(agent as never); + 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 validatePreviewSession(agent as never); + 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('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; }); }); }); From 085e742cce1349a29a40f069f6817cfb0e536334 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 6 Feb 2026 10:49:15 -0700 Subject: [PATCH 08/13] chore: cache cleanup --- src/commands/agent/preview/end.ts | 4 +++- src/previewSessionStore.ts | 16 +++++++++++++++- test/previewSessionStore.test.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/commands/agent/preview/end.ts b/src/commands/agent/preview/end.ts index a4c78cab..6d66cae7 100644 --- a/src/commands/agent/preview/end.ts +++ b/src/commands/agent/preview/end.ts @@ -17,7 +17,7 @@ import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; import { Messages, SfError } from '@salesforce/core'; import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; -import { getCachedSessionIds, validatePreviewSession } from '../../../previewSessionStore.js'; +import { getCachedSessionIds, removeCache, validatePreviewSession } from '../../../previewSessionStore.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.end'); @@ -79,6 +79,8 @@ export default class AgentPreviewEnd extends SfCommand { const tracesPath = await agent.getHistoryDir(); + await removeCache(agent); + if (agent instanceof ScriptAgent) { await agent.preview.end(); } else if (agent instanceof ProductionAgent) { diff --git a/src/previewSessionStore.ts b/src/previewSessionStore.ts index 8d78659f..e72b8887 100644 --- a/src/previewSessionStore.ts +++ b/src/previewSessionStore.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { readdir, readFile, writeFile } from 'node:fs/promises'; +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'; @@ -54,6 +54,20 @@ export async function validatePreviewSession(agent: ScriptAgent | ProductionAgen } } +/** + * 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 { + const historyDir = await agent.getHistoryDir(); + const metaPath = join(historyDir, SESSION_META_FILE); + 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. diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts index 26b1bb27..1b23151d 100644 --- a/test/previewSessionStore.test.ts +++ b/test/previewSessionStore.test.ts @@ -24,6 +24,7 @@ import { createCache, getCachedSessionIds, getCurrentSessionId, + removeCache, validatePreviewSession, } from '../src/previewSessionStore.js'; @@ -154,6 +155,36 @@ describe('previewSessionStore', () => { }); }); + 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('getCurrentSessionId', () => { it('returns undefined when no sessions', async () => { const project = makeMockProject(() => projectPath); From 8d3e80734a1cd6eaeaffac37532634f06c6cd7af Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 6 Feb 2026 11:15:52 -0700 Subject: [PATCH 09/13] feat: add 'agent preview sessions' to list sessions' --- command-snapshot.json | 8 ++++ messages/agent.preview.sessions.md | 25 +++++++++++ package.json | 2 +- schemas/agent-preview-sessions.json | 22 +++++++++ src/commands/agent/preview/sessions.ts | 62 ++++++++++++++++++++++++++ src/previewSessionStore.ts | 49 ++++++++++++++++++++ test/previewSessionStore.test.ts | 26 +++++++++++ yarn.lock | 8 ++-- 8 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 messages/agent.preview.sessions.md create mode 100644 schemas/agent-preview-sessions.json create mode 100644 src/commands/agent/preview/sessions.ts diff --git a/command-snapshot.json b/command-snapshot.json index 6ac3ec55..768826f3 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -117,6 +117,14 @@ ], "plugin": "@salesforce/plugin-agent" }, + { + "alias": [], + "command": "agent:preview:sessions", + "flagAliases": [], + "flagChars": [], + "flags": ["flags-dir", "json"], + "plugin": "@salesforce/plugin-agent" + }, { "alias": [], "command": "agent:preview:start", diff --git a/messages/agent.preview.sessions.md b/messages/agent.preview.sessions.md new file mode 100644 index 00000000..66e7cb1e --- /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.agentId + +Agent ID (authoring bundle or agent) + +# output.tableHeader.sessionId + +Session ID + +# examples + +- List all cached preview sessions: + + <%= config.bin %> <%= command.id %> 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-sessions.json b/schemas/agent-preview-sessions.json new file mode 100644 index 00000000..2e50b4e5 --- /dev/null +++ b/schemas/agent-preview-sessions.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentPreviewSessionsResult", + "definitions": { + "AgentPreviewSessionsResult": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agentId": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": ["agentId", "sessionId"], + "additionalProperties": false + } + } + } +} diff --git a/src/commands/agent/preview/sessions.ts b/src/commands/agent/preview/sessions.ts new file mode 100644 index 00000000..b123262f --- /dev/null +++ b/src/commands/agent/preview/sessions.ts @@ -0,0 +1,62 @@ +/* + * 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; 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, sessionIds } of entries) { + for (const sessionId of sessionIds) { + rows.push({ agentId, sessionId }); + } + } + + if (rows.length === 0) { + this.log(messages.getMessage('output.empty')); + return []; + } + + if (this.jsonEnabled()) { + return rows; + } + + const agentIdHeader = messages.getMessage('output.tableHeader.agentId'); + const sessionIdHeader = messages.getMessage('output.tableHeader.sessionId'); + this.table({ + data: rows, + columns: [ + { key: 'agentId', name: agentIdHeader }, + { key: 'sessionId', name: sessionIdHeader }, + ], + }); + return rows; + } +} diff --git a/src/previewSessionStore.ts b/src/previewSessionStore.ts index e72b8887..e52f8a18 100644 --- a/src/previewSessionStore.ts +++ b/src/previewSessionStore.ts @@ -59,8 +59,10 @@ export async function validatePreviewSession(agent: ScriptAgent | ProductionAgen * 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 { @@ -110,3 +112,50 @@ export async function getCurrentSessionId( const ids = await getCachedSessionIds(project, agent); return ids.length === 1 ? ids[0] : undefined; } + +export type CachedSessionEntry = { agentId: string; sessionIds: string[] }; + +/** + * List all cached preview sessions in the project, grouped by agent ID. + * Agent ID is the authoring bundle name (for script agents) or agent ID (for published agents). + * 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[] = []; + 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); + } catch { + // no sessions dir or unreadable + } + return { agentId, sessionIds }; + }) + ); + result.push(...entries.filter((e) => e.sessionIds.length > 0)); + } catch { + // no agents dir or unreadable + } + return result; +} diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts index 1b23151d..90ab87a4 100644 --- a/test/previewSessionStore.test.ts +++ b/test/previewSessionStore.test.ts @@ -24,6 +24,7 @@ import { createCache, getCachedSessionIds, getCurrentSessionId, + listCachedSessions, removeCache, validatePreviewSession, } from '../src/previewSessionStore.js'; @@ -185,6 +186,31 @@ describe('previewSessionStore', () => { }); }); + 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']); + }); + }); + describe('getCurrentSessionId', () => { it('returns undefined when no sessions', async () => { const project = makeMockProject(() => projectPath); 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" From 370f2e7f9905443b6e23846b5327c9f313316627 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 6 Feb 2026 11:20:52 -0700 Subject: [PATCH 10/13] chore: fix end typing extra --- src/components/agent-preview-react.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From fe7a3d5052372257d839a3591aab128af1c66265 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 6 Feb 2026 11:28:21 -0700 Subject: [PATCH 11/13] chore: save api-name, not id --- messages/agent.preview.sessions.md | 4 ++-- src/commands/agent/preview/sessions.ts | 16 +++++++++------ src/commands/agent/preview/start.ts | 3 ++- src/previewSessionStore.ts | 27 +++++++++++++++++++++----- test/previewSessionStore.test.ts | 12 ++++++++++++ 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/messages/agent.preview.sessions.md b/messages/agent.preview.sessions.md index 66e7cb1e..ac064d1f 100644 --- a/messages/agent.preview.sessions.md +++ b/messages/agent.preview.sessions.md @@ -10,9 +10,9 @@ List preview sessions that were started with "agent preview start" and are still No cached preview sessions found. -# output.tableHeader.agentId +# output.tableHeader.agent -Agent ID (authoring bundle or agent) +Agent (authoring bundle or API name) # output.tableHeader.sessionId diff --git a/src/commands/agent/preview/sessions.ts b/src/commands/agent/preview/sessions.ts index b123262f..dac20a41 100644 --- a/src/commands/agent/preview/sessions.ts +++ b/src/commands/agent/preview/sessions.ts @@ -21,7 +21,7 @@ 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; sessionId: string }>; +export type AgentPreviewSessionsResult = Array<{ agentId: string; displayName?: string; sessionId: string }>; export default class AgentPreviewSessions extends SfCommand { public static readonly summary = messages.getMessage('summary'); @@ -33,9 +33,9 @@ export default class AgentPreviewSessions extends SfCommand { const entries = await listCachedSessions(this.project!); const rows: AgentPreviewSessionsResult = []; - for (const { agentId, sessionIds } of entries) { + for (const { agentId, displayName, sessionIds } of entries) { for (const sessionId of sessionIds) { - rows.push({ agentId, sessionId }); + rows.push({ agentId, displayName, sessionId }); } } @@ -48,12 +48,16 @@ export default class AgentPreviewSessions extends SfCommand ({ + agent: r.displayName ?? r.agentId, + sessionId: r.sessionId, + })); this.table({ - data: rows, + data: tableData, columns: [ - { key: 'agentId', name: agentIdHeader }, + { key: 'agent', name: agentColumnHeader }, { key: 'sessionId', name: sessionIdHeader }, ], }); diff --git a/src/commands/agent/preview/start.ts b/src/commands/agent/preview/start.ts index 36c4e2f0..6b638f29 100644 --- a/src/commands/agent/preview/start.ts +++ b/src/commands/agent/preview/start.ts @@ -70,7 +70,8 @@ export default class AgentPreviewStart extends SfCommand { +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 */ - await writeFile(metaPath, JSON.stringify({}), 'utf-8'); + const meta: SessionMeta = { displayName: options?.displayName }; + await writeFile(metaPath, JSON.stringify(meta), 'utf-8'); } /** @@ -113,11 +120,11 @@ export async function getCurrentSessionId( return ids.length === 1 ? ids[0] : undefined; } -export type CachedSessionEntry = { agentId: string; sessionIds: string[] }; +export type CachedSessionEntry = { agentId: string; displayName?: string; sessionIds: string[] }; /** * List all cached preview sessions in the project, grouped by agent ID. - * Agent ID is the authoring bundle name (for script agents) or agent ID (for published agents). + * 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 { @@ -132,6 +139,7 @@ export async function listCachedSessions(project: SfProject): Promise 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, sessionIds }; + return { agentId, displayName, sessionIds }; }) ); result.push(...entries.filter((e) => e.sessionIds.length > 0)); diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts index 90ab87a4..a1a52e53 100644 --- a/test/previewSessionStore.test.ts +++ b/test/previewSessionStore.test.ts @@ -209,6 +209,18 @@ describe('previewSessionStore', () => { 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', () => { From 48cda5c81bc0c5598f33920dd2d0b141de58edee Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 6 Feb 2026 11:49:32 -0700 Subject: [PATCH 12/13] docs: update help/examples --- messages/agent.preview.end.md | 10 +++++++--- messages/agent.preview.send.md | 8 ++++++-- messages/agent.preview.start.md | 8 ++++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/messages/agent.preview.end.md b/messages/agent.preview.end.md index 6e2b8a29..ad25725d 100644 --- a/messages/agent.preview.end.md +++ b/messages/agent.preview.end.md @@ -4,7 +4,7 @@ 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 (.sfdx/agents/). 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. +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 @@ -32,9 +32,13 @@ Session traces: %s # examples -- End the single active preview session: +- End the single active preview session for a simulated agent: - <%= config.bin %> <%= command.id %> --target-org my-dev-org + <%= 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: diff --git a/messages/agent.preview.send.md b/messages/agent.preview.send.md index a38c50c9..187d4fca 100644 --- a/messages/agent.preview.send.md +++ b/messages/agent.preview.send.md @@ -32,9 +32,13 @@ Multiple preview sessions found for this agent. Specify --session-id. Sessions: # examples -- Send a message to a preview session: +- Send a message to a simulated Agent: - <%= config.bin %> <%= command.id %> --utterance "What can you help me with?" --target-org my-dev-org + <%= 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: diff --git a/messages/agent.preview.start.md b/messages/agent.preview.start.md index e7f6a856..4a70b001 100644 --- a/messages/agent.preview.start.md +++ b/messages/agent.preview.start.md @@ -28,6 +28,10 @@ Session ID: %s <%= config.bin %> <%= command.id %> --authoring-bundle My_Agent_Bundle --target-org my-dev-org -- Start a preview session with a published agent in live mode: +- Start a preview session with an authoring bundle and use real actions: - <%= config.bin %> <%= command.id %> --api-name My_Published_Agent --use-live-actions --target-org my-dev-org + <%= 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 From 8c2f89445934d6f83400fb8359ffded59260f7b3 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 6 Feb 2026 12:04:15 -0700 Subject: [PATCH 13/13] chore: schemas and snapshots --- schemas/agent-preview-sessions.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/schemas/agent-preview-sessions.json b/schemas/agent-preview-sessions.json index 2e50b4e5..37c008c0 100644 --- a/schemas/agent-preview-sessions.json +++ b/schemas/agent-preview-sessions.json @@ -10,6 +10,9 @@ "agentId": { "type": "string" }, + "displayName": { + "type": "string" + }, "sessionId": { "type": "string" }