@salesforce/agents
Version:
Client side APIs for working with Salesforce agents
1,130 lines • 45.9 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.readTranscriptEntries = exports.getAllHistory = exports.recordTraceForTurn = exports.logTurnToHistory = exports.SessionHistoryBuffer = exports.logSessionToIndex = exports.readTurnIndex = exports.readSessionTrace = exports.listSessionTraces = exports.writeTraceToHistory = exports.appendTranscriptToHistory = exports.getAgentIndexDir = exports.getHistoryDir = exports.useNamedUserJwt = exports.findLocalAgents = exports.findAuthoringBundle = exports.decodeHtmlEntities = exports.sanitizeFilename = exports.metric = void 0;
exports.getHttpStatusCode = getHttpStatusCode;
exports.requestWithEndpointFallback = requestWithEndpointFallback;
exports.detectTestRunnerFromId = detectTestRunnerFromId;
exports.determineTestRunner = determineTestRunner;
exports.createPreviewSessionCache = createPreviewSessionCache;
exports.validatePreviewSession = validatePreviewSession;
exports.removePreviewSessionCache = removePreviewSessionCache;
exports.getCachedPreviewSessionIds = getCachedPreviewSessionIds;
exports.getCurrentPreviewSessionId = getCurrentPreviewSessionId;
exports.listCachedPreviewSessions = listCachedPreviewSessions;
/*
* 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.
*/
const node_fs_1 = require("node:fs");
const promises_1 = require("node:fs/promises");
const path = __importStar(require("node:path"));
const core_1 = require("@salesforce/core");
exports.metric = ['completeness', 'coherence', 'conciseness', 'output_latency_milliseconds'];
/**
* Sanitize a filename by removing or replacing illegal characters.
* This ensures the filename is valid across different operating systems.
*
* @param filename - The filename to sanitize
* @returns A sanitized filename safe for use across operating systems
*/
const sanitizeFilename = (filename) => {
if (!filename)
return '';
// Replace colons from ISO timestamps with underscores
const sanitized = filename.replace(/:/g, '_');
// Replace other potentially problematic characters
return sanitized.replace(/[<>:"\\|?*]/g, '_');
};
exports.sanitizeFilename = sanitizeFilename;
/**
* Clean a string by replacing HTML entities with their respective characters.
*
* @param str - The string to clean.
* @returns The cleaned string with all HTML entities replaced with their respective characters.
*/
const decodeHtmlEntities = (str = '') => {
const entities = {
'"': '"',
'\': '\\',
''': "'",
'&': '&',
'<': '<',
'>': '>',
''': "'",
'°': '°',
' ': ' ',
'–': '–',
'—': '—',
'’': "'",
'‘': "'",
'“': '"',
'”': '"',
'…': '…',
'™': '™',
'©': '©',
'®': '®',
'€': '€',
'£': '£',
'¥': '¥',
'¢': '¢',
'×': '×',
'÷': '÷',
'±': '±',
'µ': 'µ',
'¶': '¶',
'§': '§',
'•': '•',
'·': '·',
};
return str.replace(/&[a-zA-Z0-9#]+;/g, (entity) => entities[entity] || entity);
};
exports.decodeHtmlEntities = decodeHtmlEntities;
/**
* Find the authoring bundle directory for a given bot name by recursively searching from a starting directory or directories.
*
* @param dirOrDirs - The directory or array of directories to start searching from
* @param botName - The name of the bot to find the authoring bundle directory for
* @returns The path to the authoring bundle directory if found, undefined otherwise
*/
const findAuthoringBundle = (dirOrDirs, botName) => {
// If it's an array of directories, search in each one
if (Array.isArray(dirOrDirs)) {
for (const dir of dirOrDirs) {
const found = (0, exports.findAuthoringBundle)(dir, botName);
if (found)
return found;
}
return undefined;
}
// Single directory search logic
const dir = dirOrDirs;
try {
const files = (0, node_fs_1.readdirSync)(dir);
// If we find aiAuthoringBundles dir, check for the expected directory structure
if (files.includes('aiAuthoringBundles')) {
const expectedPath = path.join(dir, 'aiAuthoringBundles', botName);
const statResult = (0, node_fs_1.statSync)(expectedPath, { throwIfNoEntry: false });
if (statResult?.isDirectory()) {
return expectedPath;
}
}
// Otherwise keep searching directories
for (const file of files) {
const filePath = path.join(dir, file);
const statResult = (0, node_fs_1.statSync)(filePath, { throwIfNoEntry: false });
if (statResult?.isDirectory()) {
const found = (0, exports.findAuthoringBundle)(filePath, botName);
if (found)
return found;
}
}
}
catch (err) {
// Directory doesn't exist or can't be read
return undefined;
}
return undefined;
};
exports.findAuthoringBundle = findAuthoringBundle;
/**
* Find all local agent files matching the pattern aiAuthoringBundles/<name>/<name>.agent
* Only descends into aiAuthoringBundles to check direct children (no full tree walk under it).
*
* @param dir - The directory to start searching from
* @returns Array of paths to agent files
*/
const findLocalAgents = (dir) => {
const results = [];
try {
const files = (0, node_fs_1.readdirSync)(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const statResult = (0, node_fs_1.statSync)(filePath, { throwIfNoEntry: false });
if (!statResult?.isDirectory())
continue;
if (file === 'aiAuthoringBundles') {
const bundlePath = filePath;
const children = (0, node_fs_1.readdirSync)(bundlePath);
for (const name of children) {
const agentDir = path.join(bundlePath, name);
const agentFile = path.join(agentDir, `${name}.agent`);
const fileStat = (0, node_fs_1.statSync)(agentFile, { throwIfNoEntry: false });
if (fileStat?.isFile()) {
results.push(agentFile);
}
}
}
else {
results.push(...(0, exports.findLocalAgents)(filePath));
}
}
}
catch (err) {
// Directory doesn't exist or can't be read
return [];
}
return results;
};
exports.findLocalAgents = findLocalAgents;
/**
* takes a connection and upgrades it to a NamedJWT connection
*
* @param {Connection} connection original Connection
* @returns {Promise<Connection>} upgraded connection
*/
const useNamedUserJwt = async (connection) => {
// If the connection has a refresh token, refresh the connection to ensure we have the
// latest, valid access token.
const authFields = connection.getAuthInfoFields();
if (authFields.refreshToken) {
try {
await connection.refreshAuth();
}
catch (error) {
throw core_1.SfError.create({
name: 'ApiAccessError',
message: 'Error refreshing connection',
cause: error,
});
}
}
const { accessToken, instanceUrl } = connection.getConnectionOptions();
if (!instanceUrl) {
throw core_1.SfError.create({
name: 'ApiAccessError',
message: 'Missing Instance URL for org connection',
});
}
if (!accessToken) {
throw core_1.SfError.create({
name: 'ApiAccessError',
message: 'Missing Access Token for org connection',
});
}
const url = `${instanceUrl}/agentforce/bootstrap/nameduser`;
// For the nameduser endpoint request to work we need to delete the access token
delete connection.accessToken;
try {
const response = await connection.request({
method: 'GET',
url,
headers: {
'Content-Type': 'application/json',
Cookie: `sid=${accessToken}`,
},
}, { retry: { maxRetries: 3 } });
// Validate the response contains a valid access token
if (!response) {
throw core_1.SfError.create({
name: 'ApiAccessError',
message: 'Error obtaining API token: empty response.',
});
}
if (!response.access_token || typeof response.access_token !== 'string' || response.access_token.trim() === '') {
throw core_1.SfError.create({
name: 'ApiAccessError',
message: 'Error obtaining API token: invalid or missing access token.',
});
}
// Validate token format is JWT (three parts separated by dots)
const tokenParts = response.access_token.split('.');
if (tokenParts.length !== 3) {
throw core_1.SfError.create({
name: 'ApiAccessError',
message: 'Error obtaining API token: access token does not have valid JWT format.',
});
}
connection.accessToken = response.access_token;
return connection;
}
catch (error) {
// If it's already an SfError with our specific message, re-throw it as-is
if (error instanceof core_1.SfError && error.name === 'ApiAccessError') {
error.actions = [
'If using your own connected app or ECA, ensure it grants access to the SFAP APIs by providing these scopes:',
' * Access chatbot services (chatbot_api)',
' * Access the Salesforce API Platform (sfap_api)',
' * Manage user data via Web browsers (web)',
];
throw error;
}
// Otherwise wrap it with a generic error
throw core_1.SfError.create({
name: 'ApiAccessError',
message: 'Error obtaining API token',
cause: error,
});
}
};
exports.useNamedUserJwt = useNamedUserJwt;
const resolveProjectLocalSfdx = async () => {
try {
const project = await core_1.SfProject.resolve();
return path.join(project.getPath(), '.sfdx');
}
catch (_e) {
return undefined;
}
};
/**
* returns a path, and ensures it's created, to the agents history directory
*
* Initialize session directory
* Session directory structure:
* .sfdx/agents/<agentId>/sessions/<sessionId>/
* ├── transcript.jsonl # All transcript entries (one per line)
* ├── turn-index.json # Turn-trace correlation index
* ├── traces/ # Individual trace files
* │ ├── <planId1>.json
* │ └── <planId2>.json
* └── metadata.json # Session metadata (start time, end time, planIds, etc.)
*
* @param {string} agentId gotten from Agent.getAgentIdForStorage()
* @param {string} sessionId the preview's start call .SessionId
* @returns {Promise<string>} path to where history/metadata/transcripts are stored inside of local .sfdx
*/
const getHistoryDir = async (agentId, sessionId) => {
const agentIndexDir = await (0, exports.getAgentIndexDir)(agentId);
const dir = path.join(agentIndexDir, 'sessions', sessionId);
await (0, promises_1.mkdir)(dir, { recursive: true });
return dir;
};
exports.getHistoryDir = getHistoryDir;
/**
* Get the path to the agent index directory. Create the directory if it doesn't exist.
*
* .sfdx/agents/<agentId>/ <-- returns this directory path
* ├── index.md # Session index file
* │── sessions/ # Session directories
* │ ├── <sessionId1>/ # Session 1 directory
* │ └── <sessionId2>/ # Session 2 directory
*
* @param {string} agentId
* @returns {Promise<string>} path to the agent index directory
*/
const getAgentIndexDir = async (agentId) => {
const base = (await resolveProjectLocalSfdx()) ?? path.join(process.cwd(), '.sfdx');
const dir = path.join(base, 'agents', agentId);
await (0, promises_1.mkdir)(dir, { recursive: true });
return dir;
};
exports.getAgentIndexDir = getAgentIndexDir;
/**
* Append a transcript entry to the transcript.jsonl transcript file
*
* @param {TranscriptEntry} entry to save
* @param {string} sessionDir the preview's start call .SessionId
* @returns {Promise<void>}
*/
const appendTranscriptToHistory = async (entry, sessionDir) => {
const transcriptPath = path.join(sessionDir, 'transcript.jsonl');
const line = `${JSON.stringify(entry)}\n`;
await (0, promises_1.appendFile)(transcriptPath, line, 'utf-8');
};
exports.appendTranscriptToHistory = appendTranscriptToHistory;
/**
* writes a trace to <plan-id>.json in history directory
*
* @param {string} planId
* @param {PlannerResponse | undefined} trace
* @param {string} historyDir
* @returns {Promise<void>}
*/
const writeTraceToHistory = async (planId, trace, historyDir) => {
const tracesDir = path.join(historyDir, 'traces');
await (0, promises_1.mkdir)(tracesDir, { recursive: true });
const tracePath = path.join(tracesDir, `${planId}.json`);
await (0, promises_1.writeFile)(tracePath, JSON.stringify(trace ?? {}, null, 2), 'utf-8');
};
exports.writeTraceToHistory = writeTraceToHistory;
/**
* List trace files for a given agent session.
*
* Returns one entry per .json file in the session's traces/ directory.
* File path is absolute. Returns an empty array if the traces directory does not exist.
*/
const listSessionTraces = async (agentId, sessionId) => {
const historyDir = await (0, exports.getHistoryDir)(agentId, sessionId);
const tracesDir = path.join(historyDir, 'traces');
try {
const files = await (0, promises_1.readdir)(tracesDir);
return await Promise.all(files
.filter((f) => f.endsWith('.json'))
.map(async (f) => {
const filePath = path.join(tracesDir, f);
const s = await (0, promises_1.stat)(filePath);
return { planId: path.basename(f, '.json'), path: filePath, size: s.size, mtime: s.mtime };
}));
}
catch {
return [];
}
};
exports.listSessionTraces = listSessionTraces;
/**
* Read a single trace file by planId. Returns null if the file does not exist or cannot be parsed.
*/
const readSessionTrace = async (agentId, sessionId, planId) => {
const historyDir = await (0, exports.getHistoryDir)(agentId, sessionId);
const tracePath = path.join(historyDir, 'traces', `${planId}.json`);
try {
const raw = await (0, promises_1.readFile)(tracePath, 'utf-8');
return JSON.parse(raw);
}
catch {
return null;
}
};
exports.readSessionTrace = readSessionTrace;
/**
* Read the turn-index.json for a session. Returns null if not found.
*/
const readTurnIndex = async (agentId, sessionId) => {
const historyDir = await (0, exports.getHistoryDir)(agentId, sessionId);
const turnIndexPath = path.join(historyDir, 'turn-index.json');
try {
const raw = await (0, promises_1.readFile)(turnIndexPath, 'utf-8');
return JSON.parse(raw);
}
catch {
return null;
}
};
exports.readTurnIndex = readTurnIndex;
/**
* Write or append a session line to .sfdx/agents/<agentId>/index.md.
* If the file does not exist, creates it with a header and the session line.
* If it exists, appends the new session line.
*/
const logSessionToIndex = async (agentDir, options) => {
const indexPath = path.join(agentDir, 'index.md');
const modeLabel = options.simulated ? 'simulated' : 'live';
const sessionLine = `- **${options.startTime}** | \`${options.sessionId}\` | ${modeLabel}`;
if (!(0, node_fs_1.existsSync)(indexPath)) {
const initialContent = `# ${options.agentId} - Sessions\n\n${sessionLine}\n`;
await (0, promises_1.writeFile)(indexPath, initialContent, 'utf-8');
}
else {
await (0, promises_1.appendFile)(indexPath, `${sessionLine}\n`, 'utf-8');
}
};
exports.logSessionToIndex = logSessionToIndex;
/**
* Helper function to create a summary with truncation
*/
function createSummary(text, multiModal) {
const MAX_SUMMARY_LENGTH = 100;
if (multiModal) {
return { summary: `[${multiModal}]`, truncated: false };
}
if (!text) {
return { summary: '', truncated: false };
}
if (text.length <= MAX_SUMMARY_LENGTH) {
return { summary: text, truncated: false };
}
return {
summary: text.substring(0, MAX_SUMMARY_LENGTH) + '...',
truncated: true,
};
}
/**
* In-memory buffer for session history to minimize file I/O during conversation
*/
class SessionHistoryBuffer {
sessionDir;
sessionId;
agentId;
created;
mockMode;
turnEntries = [];
planIds = new Set();
constructor(sessionDir, sessionId, agentId, created, mockMode) {
this.sessionDir = sessionDir;
this.sessionId = sessionId;
this.agentId = agentId;
this.created = created;
this.mockMode = mockMode;
}
/**
* Create a SessionHistoryBuffer from existing session data on disk
* Used when resuming an existing session
*/
static async fromDisk(sessionDir, sessionId, agentId) {
// Read existing metadata and turn-index files
const metadataPath = path.join(sessionDir, 'metadata.json');
const turnIndexPath = path.join(sessionDir, 'turn-index.json');
let metadata = null;
let turnIndex = null;
try {
const metadataContent = await (0, promises_1.readFile)(metadataPath, 'utf-8');
metadata = JSON.parse(metadataContent);
}
catch {
// Metadata doesn't exist yet - that's ok for a new session
}
try {
const turnIndexContent = await (0, promises_1.readFile)(turnIndexPath, 'utf-8');
turnIndex = JSON.parse(turnIndexContent);
}
catch {
// Turn index doesn't exist yet - that's ok for a new session
}
// Create buffer with metadata
const buffer = new SessionHistoryBuffer(sessionDir, sessionId, agentId, metadata?.startTime ?? new Date().toISOString(), metadata?.mockMode);
// Load existing turns and planIds into buffer
if (turnIndex?.turns) {
turnIndex.turns.forEach((turn) => buffer.addTurn(turn));
}
if (metadata?.planIds) {
metadata.planIds.forEach((planId) => buffer.addPlanId(planId));
}
// Return buffer and current turn count
const turnCount = turnIndex?.turns.length ?? 0;
return { buffer, turnCount };
}
/**
* Add a turn to the buffer (no file I/O)
*/
addTurn(entry) {
this.turnEntries.push(entry);
}
/**
* Add a planId to the buffer (no file I/O)
*/
addPlanId(planId) {
this.planIds.add(planId);
}
/**
* Update an existing turn with trace info (no file I/O)
*/
updateTurnWithTrace(turnNumber, planId) {
const turn = this.turnEntries.find((t) => t.turn === turnNumber);
if (turn) {
turn.traceFile = `traces/${planId}.json`;
turn.planId = planId;
}
}
/**
* Flush all buffered data to disk
* Called at session start (to create initial files), after each turn (to keep real-time), and at session end (to finalize)
*/
async flush(endTime) {
const turnIndexPath = path.join(this.sessionDir, 'turn-index.json');
const metadataPath = path.join(this.sessionDir, 'metadata.json');
const turnIndex = {
version: '1.0',
sessionId: this.sessionId,
agentId: this.agentId,
created: this.created,
turns: this.turnEntries,
};
const metadata = {
sessionId: this.sessionId,
agentId: this.agentId,
startTime: this.created,
planIds: Array.from(this.planIds),
};
if (endTime) {
metadata.endTime = endTime;
}
if (this.mockMode) {
metadata.mockMode = this.mockMode;
}
// Write both files in parallel
await Promise.all([
(0, promises_1.writeFile)(turnIndexPath, JSON.stringify(turnIndex, null, 2), 'utf-8'),
(0, promises_1.writeFile)(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'),
]);
}
}
exports.SessionHistoryBuffer = SessionHistoryBuffer;
/**
* Log a turn to history using buffer (fast, no read-modify-write)
*
* @param {TranscriptEntry} entry the transcript entry to log
* @param {number} turnNumber the turn number (1-based)
* @param {string} sessionDir path to the session directory
* @param {SessionHistoryBuffer} buffer buffer for batched writes
* @returns {Promise<void>}
*/
const logTurnToHistory = async (entry, turnNumber, sessionDir, buffer) => {
// Always append to transcript immediately (fast append-only operation)
await (0, exports.appendTranscriptToHistory)(entry, sessionDir);
// Add turn to in-memory buffer (no I/O)
const { summary, truncated } = createSummary(entry.text, null);
buffer.addTurn({
turn: turnNumber,
timestamp: entry.timestamp,
role: entry.role,
summary,
summaryTruncated: truncated,
multiModal: null,
traceFile: null,
planId: null,
reason: entry.reason,
});
};
exports.logTurnToHistory = logTurnToHistory;
/**
* Extract HTTP status code from API errors. Supports:
* - ERROR_HTTP_404 / ERROR_HTTP_500 style (name, errorCode, or data.errorCode)
* - Numeric statusCode on error, cause, or response
*/
function getHttpStatusCode(err) {
return getHttpStatusCodeInternal(err, new Set());
}
/**
* Internal implementation with circular reference tracking
*/
function getHttpStatusCodeInternal(err, visited) {
// Prevent infinite recursion from circular references
if (visited.has(err)) {
return undefined;
}
visited.add(err);
const e = err;
const codeStr = e?.name ?? e?.errorCode ?? e?.data?.errorCode;
if (typeof codeStr === 'string') {
const match = /ERROR_HTTP_(\d+)/i.exec(codeStr);
if (match) {
return Number.parseInt(match[1], 10);
}
}
return e?.statusCode ?? getHttpStatusCodeInternal(e?.cause, visited) ?? e?.response?.statusCode;
}
/**
* Makes an API request with automatic endpoint fallback.
* Tries api.salesforce.com first, then test.api.salesforce.com on 404, then dev.api.salesforce.com on 404.
*
* @param connection - The Salesforce connection
* @param requestInfo - The request information (url, method, headers, body, etc.)
* @param options - Optional retry/timeout options
* @returns The API response
* @throws SfError if all endpoints return 404, or immediately on non-404 errors
*/
async function requestWithEndpointFallback(connection, requestInfo, options) {
const endpoints = ['', 'test.', 'dev.']; // Try production, test, dev in that order
const attemptedEndpoints = [];
const logger = core_1.Logger.childFromRoot('AgentApiRequest');
let lastError;
for (const endpoint of endpoints) {
// Replace the domain with the endpoint variant
const modifiedUrl = requestInfo.url.replace(/https:\/\/(?:test\.|dev\.)?api\.salesforce\.com/, `https://${endpoint}api.salesforce.com`);
attemptedEndpoints.push(`${endpoint || 'production '}api.salesforce.com`);
try {
// eslint-disable-next-line no-await-in-loop
return await connection.request({
...requestInfo,
url: modifiedUrl,
}, options);
}
catch (error) {
const statusCode = getHttpStatusCode(error);
logger.debug(`Request failed for url ${modifiedUrl} with status code ${statusCode ?? 'unknown'}`);
if (statusCode === 404) {
lastError = error;
continue; // Try next endpoint
}
// Not a 404, throw immediately
throw error;
}
}
// All endpoints failed with 404
logger.debug(`Attempted endpoints: ${attemptedEndpoints.join(', ')}`);
throw core_1.SfError.create({
name: 'AgentApiNotFound',
message: `Unable to access the Salesforce Agent APIs. Ensure the user '${connection.getUsername() ?? ''}' has the necessary permissions and authorization to perform this action.`,
cause: lastError,
});
}
/**
* Record a trace for a turn using buffer (fast, minimal I/O)
*
* @param {string} historyDir path to the session directory
* @param {number} turnNumber the turn number that generated this trace
* @param {string} planId the plan ID for this trace
* @param {PlannerResponse | undefined} trace the trace data to write
* @param {SessionHistoryBuffer} buffer buffer for batched updates
* @returns {Promise<void>}
*/
const recordTraceForTurn = async (historyDir, turnNumber, planId, trace, buffer) => {
// Write the trace file immediately (one-time write)
const tracesDir = path.join(historyDir, 'traces');
const tracePath = path.join(tracesDir, `${planId}.json`);
await (0, promises_1.mkdir)(tracesDir, { recursive: true });
await (0, promises_1.writeFile)(tracePath, JSON.stringify(trace ?? {}, null, 2), 'utf-8');
// Update in memory (no I/O)
buffer.updateTurnWithTrace(turnNumber, planId);
buffer.addPlanId(planId);
};
exports.recordTraceForTurn = recordTraceForTurn;
/**
* Find the most recent session ID for an agent by checking metadata.json startTime
*
* @param agentId gotten from Agent.getAgentIdForStorage()
* @returns The most recent sessionId, or undefined if no sessions found
*/
const findMostRecentSessionId = async (agentId) => {
const base = (await resolveProjectLocalSfdx()) ?? path.join(process.cwd(), '.sfdx');
const sessionsDir = path.join(base, 'agents', agentId, 'sessions');
try {
const sessionDirs = await (0, promises_1.readdir)(sessionsDir);
if (sessionDirs.length === 0) {
return undefined;
}
// Get all sessions with their metadata to find the most recent
const sessionPromises = sessionDirs.map(async (sessionId) => {
const sessionPath = path.join(sessionsDir, sessionId);
const metadataPath = path.join(sessionPath, 'metadata.json');
try {
const metadataData = await (0, promises_1.readFile)(metadataPath, 'utf-8');
const metadata = JSON.parse(metadataData);
const statResult = await (0, promises_1.stat)(sessionPath);
return {
sessionId,
startTime: metadata.startTime ? new Date(metadata.startTime).getTime() : statResult.mtimeMs,
mtime: statResult.mtimeMs,
};
}
catch {
// If metadata doesn't exist or can't be read, use directory modification time
try {
const statResult = await (0, promises_1.stat)(sessionPath);
return {
sessionId,
startTime: statResult.mtimeMs,
mtime: statResult.mtimeMs,
};
}
catch {
return null;
}
}
});
const sessions = (await Promise.all(sessionPromises)).filter((s) => s !== null);
if (sessions.length === 0) {
return undefined;
}
// Sort by startTime (most recent first), fallback to mtime
sessions.sort((a, b) => b.startTime - a.startTime);
return sessions[0].sessionId;
}
catch {
// Sessions directory doesn't exist or can't be read
return undefined;
}
};
/**
* Get all history data for a session including metadata, transcript, and traces
*
* @param agentId gotten from Agent.getAgentIdForStorage()
* @param sessionId optional - the preview sessions' ID, gotten originally from /start .SessionId. If not provided, returns the most recent conversation
* @returns Object containing parsed metadata, transcript entries, and traces
*/
const getAllHistory = async (agentId, sessionId) => {
// If sessionId is not provided, find the most recent session
let actualSessionId = sessionId;
if (!actualSessionId) {
actualSessionId = await findMostRecentSessionId(agentId);
if (!actualSessionId) {
throw core_1.SfError.create({
name: 'NoSessionFound',
message: `No sessions found for agent ${agentId}`,
});
}
}
const historyDir = await (0, exports.getHistoryDir)(agentId, actualSessionId);
const result = {
metadata: null,
transcript: [],
traces: [],
};
// Read metadata.json
try {
const metadataPath = path.join(historyDir, 'metadata.json');
const metadataData = await (0, promises_1.readFile)(metadataPath, 'utf-8');
result.metadata = JSON.parse(metadataData);
}
catch {
// Metadata file doesn't exist or can't be read - leave as null
}
// Read transcript.jsonl
try {
const transcriptPath = path.join(historyDir, 'transcript.jsonl');
const transcriptData = await (0, promises_1.readFile)(transcriptPath, 'utf-8');
result.transcript = transcriptData
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line));
}
catch {
// Transcript file doesn't exist or can't be read - leave as empty array
}
// Read all trace files from traces/ directory
try {
const tracesDir = path.join(historyDir, 'traces');
const files = await (0, promises_1.readdir)(tracesDir);
const tracePromises = files
.filter((file) => file.endsWith('.json'))
.map(async (file) => {
const tracePath = path.join(tracesDir, file);
const traceData = await (0, promises_1.readFile)(tracePath, 'utf-8');
return JSON.parse(traceData);
});
result.traces = await Promise.all(tracePromises);
}
catch {
// Traces directory doesn't exist or can't be read - leave as empty array
}
return result;
};
exports.getAllHistory = getAllHistory;
/**
* Read and parse the last conversation's transcript entries from JSON.
*
* @param agentId gotten from Agent.getAgentIdForStorage()
* @param sessionId the preview sessions' ID, gotten originally from /start .SessionId
* @returns Array of TranscriptEntry in file order (chronological append order).
*/
const readTranscriptEntries = async (agentId, sessionId) => {
const filePath = await (0, exports.getHistoryDir)(agentId, sessionId);
try {
const data = await (0, promises_1.readFile)(filePath, 'utf-8');
return data
.split('\n')
.filter((l) => l.trim().length > 0)
.map((l) => JSON.parse(l));
}
catch (_e) {
return [];
}
};
exports.readTranscriptEntries = readTranscriptEntries;
const TESTING_CENTER_PREFIX = '4KB';
const AGENTFORCE_STUDIO_PREFIX = '3A2';
/** Detects the test runner from a run ID's Salesforce ID prefix (`3A2` = Agentforce Studio, `4KB` = Testing Center). */
function detectTestRunnerFromId(runId) {
if (runId.startsWith(AGENTFORCE_STUDIO_PREFIX))
return 'agentforce-studio';
if (runId.startsWith(TESTING_CENTER_PREFIX))
return 'testing-center';
return undefined;
}
/**
* Determines which test runner to use based on available metadata types in the org.
*
* This function checks for the presence of:
* - `AiEvaluationDefinition` (Testing Center)
* - `AiTestingDefinition` (Agentforce Studio)
*
* If a test definition with the same name exists in both metadata types, an error is thrown
* to prevent ambiguity.
*
* @param connection - The Salesforce connection
* @param testDefinitionName - Optional test definition name to check for conflicts
* @returns 'agentforce-studio' if only Agentforce Studio metadata exists, 'testing-center' if only Testing Center metadata exists
* @throws {SfError} if both metadata types exist with the same test definition name
* @throws {SfError} if neither metadata type exists
*
* @example
* ```typescript
* const runnerType = await determineTestRunner(connection, 'MyTestSuite');
* if (runnerType === 'agentforce-studio') {
* const tester = new AgentforceStudioTester(connection);
* } else {
* const tester = new AgentTester(connection);
* }
* ```
*/
async function determineTestRunner(connection, testDefinitionName) {
// Query both metadata types in parallel
const [tcDefs, asDefs] = await Promise.all([
connection.metadata.list({ type: 'AiEvaluationDefinition' }).catch(() => []),
connection.metadata.list({ type: 'AiTestingDefinition' }).catch(() => []),
]);
// If a specific test name is provided, check for conflicts
if (testDefinitionName && tcDefs.length > 0 && asDefs.length > 0) {
const tcNames = new Set(tcDefs.map((def) => def.fullName));
const asNames = new Set(asDefs.map((def) => def.fullName));
if (tcNames.has(testDefinitionName) && asNames.has(testDefinitionName)) {
throw core_1.SfError.create({
name: 'AmbiguousTestDefinition',
message: `'${testDefinitionName}' exists in both Testing Center (AiEvaluationDefinition) and Agentforce Studio (AiTestingDefinition).`,
});
}
if (tcNames.has(testDefinitionName)) {
return 'testing-center';
}
if (asNames.has(testDefinitionName)) {
return 'agentforce-studio';
}
}
if (tcDefs.length > 0 && asDefs.length === 0) {
return 'testing-center';
}
if (asDefs.length > 0 && tcDefs.length === 0) {
return 'agentforce-studio';
}
// Neither exists
throw core_1.SfError.create({
name: 'NoTestDefinitionsFound',
message: 'No test definitions found in the org. Expected either AiEvaluationDefinition (Testing Center) or AiTestingDefinition (Agentforce Studio) metadata.',
});
}
// ====================================================
// Preview Session Store
// ====================================================
const SESSION_META_FILE = 'session-meta.json';
const SESSION_INDEX_FILE = 'index.json';
async function readPreviewSessionIndex(indexPath) {
try {
const raw = await (0, promises_1.readFile)(indexPath, 'utf-8');
return JSON.parse(raw);
}
catch {
return [];
}
}
/**
* Atomically read-modify-write the preview sessions index.
* Writes to a temp file then renames to avoid partial writes and reduce
* the window for concurrent-write races (last writer wins, no silent drops).
* Propagates errors so callers are aware of index failures.
*/
async function updatePreviewSessionIndex(indexPath, updater) {
const index = await readPreviewSessionIndex(indexPath);
const updated = updater(index);
const tmpPath = `${indexPath}.tmp`;
await (0, promises_1.writeFile)(tmpPath, JSON.stringify(updated, null, 2), 'utf-8');
await (0, promises_1.rename)(tmpPath, indexPath);
}
/**
* Save a marker so send/end can validate that the session was started for this agent.
* Caller must have started the session (agent has sessionId set). Uses agent.getHistoryDir() for the path.
* Pass displayName (authoring bundle name or production agent API name) so "agent preview sessions" can show it.
*/
async function createPreviewSessionCache(agent, options) {
const historyDir = await agent.getHistoryDir();
const metaPath = path.join(historyDir, SESSION_META_FILE);
const meta = {
displayName: options?.displayName,
timestamp: new Date().toISOString(),
sessionType: options?.sessionType,
};
await (0, promises_1.writeFile)(metaPath, JSON.stringify(meta), 'utf-8');
// Update the sessions index for ordered browsing
const sessionId = path.basename(historyDir);
const sessionsDir = path.dirname(historyDir);
const indexPath = path.join(sessionsDir, SESSION_INDEX_FILE);
await updatePreviewSessionIndex(indexPath, (index) => {
if (!index.some((e) => e.sessionId === sessionId)) {
index.push({
sessionId,
displayName: meta.displayName,
timestamp: meta.timestamp,
sessionType: meta.sessionType,
});
}
return index;
});
}
/**
* 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.
*/
async function validatePreviewSession(agent) {
const historyDir = await agent.getHistoryDir();
const metaPath = path.join(historyDir, SESSION_META_FILE);
try {
await (0, promises_1.readFile)(metaPath, 'utf-8');
}
catch (error) {
throw core_1.SfError.create({
message: 'No preview session found for this session ID. Run "sf agent preview start" first.',
name: 'PreviewSessionNotFound',
cause: error,
});
}
}
/**
* 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.
*/
async function removePreviewSessionCache(agent) {
const historyDir = await agent.getHistoryDir();
const metaPath = path.join(historyDir, SESSION_META_FILE);
try {
await (0, promises_1.unlink)(metaPath);
}
catch {
// already removed or never created
}
// Remove entry from the sessions index
const sessionId = path.basename(historyDir);
const sessionsDir = path.dirname(historyDir);
const indexPath = path.join(sessionsDir, SESSION_INDEX_FILE);
await updatePreviewSessionIndex(indexPath, (index) => index.filter((e) => e.sessionId !== sessionId));
}
/**
* 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/<agentId>/sessions/<sessionId>/session-meta.json.
*/
async function getCachedPreviewSessionIds(project, agent) {
const agentId = await agent.getAgentIdForStorage();
const base = path.join(project.getPath(), '.sfdx');
const sessionsDir = path.join(base, 'agents', agentId, 'sessions');
const sessionIds = [];
try {
const entries = await (0, promises_1.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 (0, promises_1.readFile)(path.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).
*/
async function getCurrentPreviewSessionId(project, agent) {
const ids = await getCachedPreviewSessionIds(project, agent);
return ids.length === 1 ? ids[0] : undefined;
}
/**
* List all cached preview sessions in the project, grouped by agent ID.
* displayName (when present in session-meta.json) is the authoring bundle name or production agent API name for display.
* Use this to show users which sessions exist so they can end or clean up.
*/
async function listCachedPreviewSessions(project) {
const base = path.join(project.getPath(), '.sfdx', 'agents');
const result = [];
try {
const agentDirs = await (0, promises_1.readdir)(base, { withFileTypes: true });
const entries = await Promise.all(agentDirs
.filter((ent) => ent.isDirectory())
.map(async (ent) => {
const agentId = ent.name;
const sessionsDir = path.join(base, agentId, 'sessions');
let sessions = [];
let displayName;
try {
// Prefer the index for ordered, metadata-rich results
const index = await readPreviewSessionIndex(path.join(sessionsDir, SESSION_INDEX_FILE));
if (index.length > 0) {
// Verify each indexed session still has its marker file (guard against manual cleanup)
const verified = await Promise.all(index.map(async (entry) => {
try {
await (0, promises_1.readFile)(path.join(sessionsDir, entry.sessionId, SESSION_META_FILE), 'utf-8');
return entry;
}
catch {
return null;
}
}));
sessions = verified
.filter((e) => e !== null)
.map(({ sessionId, timestamp, sessionType }) => ({ sessionId, timestamp, sessionType }));
displayName = index.find((e) => e.displayName !== undefined)?.displayName;
}
else {
// Fallback: scan directories (no index yet, e.g. sessions started before this feature)
const sessionDirs = await (0, promises_1.readdir)(sessionsDir, { withFileTypes: true });
const sessionInfos = await Promise.all(sessionDirs
.filter((s) => s.isDirectory())
.map(async (s) => {
try {
const raw = await (0, promises_1.readFile)(path.join(sessionsDir, s.name, SESSION_META_FILE), 'utf-8');
const meta = JSON.parse(raw);
return {
sessionId: s.name,
timestamp: meta.timestamp,
sessionType: meta.sessionType,
displayName: meta.displayName,
};
}
catch {
return null;
}
}));
const validSessions = sessionInfos.filter((s) => s !== null);
sessions = validSessions.map(({ sessionId, timestamp, sessionType }) => ({
sessionId,
timestamp,
sessionType,
}));
displayName = validSessions[0]?.displayName;
}
}
catch {
// no sessions dir or unreadable
}
return { agentId, displayName, sessions };
}));
result.push(...entries.filter((e) => e.sessions.length > 0));
}
catch {
// no agents dir or unreadable
}
return result;
}
//# sourceMappingURL=utils.js.map