UNPKG

@salesforce/agents

Version:

Client side APIs for working with Salesforce agents

1,130 lines 45.9 kB
"use strict"; 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 = { '&quot;': '"', '&#92;': '\\', '&apos;': "'", '&amp;': '&', '&lt;': '<', '&gt;': '>', '&#39;': "'", '&deg;': '°', '&nbsp;': ' ', '&ndash;': '–', '&mdash;': '—', '&rsquo;': "'", '&lsquo;': "'", '&ldquo;': '"', '&rdquo;': '"', '&hellip;': '…', '&trade;': '™', '&copy;': '©', '&reg;': '®', '&euro;': '€', '&pound;': '£', '&yen;': '¥', '&cent;': '¢', '&times;': '×', '&divide;': '÷', '&plusmn;': '±', '&micro;': 'µ', '&para;': '¶', '&sect;': '§', '&bull;': '•', '&middot;': '·', }; 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