diff --git a/package.json b/package.json index e66182e..386d2fa 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.19.8", + "@salesforce/agents": "^0.20.0", "@salesforce/core": "^8.23.7", "@salesforce/kit": "^3.2.3", "@salesforce/sf-plugins-core": "^12.2.6", diff --git a/src/commands/agent/preview.ts b/src/commands/agent/preview.ts index a85f6d1..2a11469 100644 --- a/src/commands/agent/preview.ts +++ b/src/commands/agent/preview.ts @@ -18,7 +18,7 @@ import * as path from 'node:path'; import { join, resolve } from 'node:path'; import { globSync } from 'glob'; import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; -import { AuthInfo, Connection, Lifecycle, Messages, SfError } from '@salesforce/core'; +import { AuthInfo, Connection, Lifecycle, Logger, Messages, SfError } from '@salesforce/core'; import React from 'react'; import { render } from 'ink'; import { @@ -35,6 +35,14 @@ import { AgentPreviewReact } from '../../components/agent-preview-react.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview'); +let logger: Logger; +const getLogger = (): Logger => { + if (!logger) { + logger = Logger.childFromRoot('plugin-agent-preview'); + } + return logger; +}; + type BotVersionStatus = { Status: 'Active' | 'Inactive' }; export type AgentData = { @@ -195,6 +203,7 @@ export default class AgentPreview extends SfCommand { outputDir, isLocalAgent: selectedAgent.source === AgentSource.SCRIPT, apexDebug: flags['apex-debug'], + logger: getLogger(), }), { exitOnCtrlC: false } ); diff --git a/src/components/agent-preview-react.tsx b/src/components/agent-preview-react.tsx index d293304..79b2ac4 100644 --- a/src/components/agent-preview-react.tsx +++ b/src/components/agent-preview-react.tsx @@ -21,9 +21,10 @@ import { resolve } from 'node:path'; import React from 'react'; import { Box, Text, useInput } from 'ink'; import TextInput from 'ink-text-input'; -import { Connection, SfError, Lifecycle } from '@salesforce/core'; +import { Connection, SfError, Lifecycle, Logger } from '@salesforce/core'; import { AgentPreviewBase, AgentPreviewSendResponse, writeDebugLog } from '@salesforce/agents'; import { sleep, env } from '@salesforce/kit'; +import { PlannerResponse } from '@salesforce/agents/lib/types.js'; // Component to show a simple typing animation function Typing(): React.ReactNode { @@ -52,7 +53,8 @@ function Typing(): React.ReactNode { export const saveTranscriptsToFile = ( outputDir: string, messages: Array<{ timestamp: Date; role: string; content: string }>, - responses: AgentPreviewSendResponse[] + responses: AgentPreviewSendResponse[], + traces?: PlannerResponse[] ): void => { if (!outputDir) return; fs.mkdirSync(outputDir, { recursive: true }); @@ -62,6 +64,29 @@ export const saveTranscriptsToFile = ( const responsesPath = path.join(outputDir, 'responses.json'); fs.writeFileSync(responsesPath, JSON.stringify(responses, null, 2)); + + if (traces) { + const tracesPath = path.join(outputDir, 'traces.json'); + fs.writeFileSync(tracesPath, JSON.stringify(traces, null, 2)); + } +}; + +export const getTraces = async ( + agent: AgentPreviewBase, + sessionId: string, + messageIds: string[], + logger: Logger +): Promise => { + if (messageIds.length > 0) { + try { + const traces = await agent.traces(sessionId, messageIds); + return traces; + } catch (e) { + const sfError = SfError.wrap(e); + logger.info(`Error obtaining traces: ${sfError.name} - ${sfError.message}`, { sessionId, messageIds }); + } + } + return []; }; /** @@ -78,6 +103,7 @@ export function AgentPreviewReact(props: { readonly outputDir: string | undefined; readonly isLocalAgent: boolean; readonly apexDebug: boolean | undefined; + readonly logger: Logger; }): React.ReactNode { const [messages, setMessages] = React.useState>([]); const [header, setHeader] = React.useState('Starting session...'); @@ -96,8 +122,9 @@ export function AgentPreviewReact(props: { const [tempDir, setTempDir] = React.useState(''); const [responses, setResponses] = React.useState([]); const [apexDebugLogs, setApexDebugLogs] = React.useState([]); + const [messageIds, setMessageIds] = React.useState([]); - const { connection, agent, name, outputDir, isLocalAgent, apexDebug } = props; + const { connection, agent, name, outputDir, isLocalAgent, apexDebug, logger } = props; useInput((input, key) => { // If user is in directory input and presses ESC, cancel and exit without saving @@ -222,7 +249,9 @@ export function AgentPreviewReact(props: { const sessionDir = path.join(finalDir, `${dateForDir}--${sessionId || 'session'}`); fs.mkdirSync(sessionDir, { recursive: true }); - saveTranscriptsToFile(sessionDir, messages, responses); + const traces = await getTraces(agent, sessionId, messageIds, logger); + + saveTranscriptsToFile(sessionDir, messages, responses, traces); // Write apex debug logs if any if (apexDebug) { @@ -246,7 +275,7 @@ export function AgentPreviewReact(props: { } }; void saveAndExit(); - }, [saveConfirmed, saveDir, messages, responses, sessionId, apexDebug, connection]); + }, [saveConfirmed, saveDir, messages, responses, sessionId, apexDebug, connection, agent, messageIds, logger]); return ( @@ -395,6 +424,7 @@ export function AgentPreviewReact(props: { // Add the agent's response to the chat setMessages((prev) => [...prev, { role: name, content: message, timestamp: new Date() }]); + setMessageIds((prev) => [...prev, response.messages[0].planId]); // Apex debug logs will be saved when user exits and chooses to save } catch (e) { @@ -422,6 +452,7 @@ export function AgentPreviewReact(props: { Session Ended {tempDir ? Conversation log: {tempDir}/transcript.json : null} {tempDir ? API transactions: {tempDir}/responses.json : null} + {tempDir ? Traces: {tempDir}/traces.json : null} {apexDebugLogs.length > 0 && tempDir && Apex Debug Logs saved to: {tempDir}} ) : null} diff --git a/test/components/agent-preview-react.test.ts b/test/components/agent-preview-react.test.ts index d392606..57c1045 100644 --- a/test/components/agent-preview-react.test.ts +++ b/test/components/agent-preview-react.test.ts @@ -19,8 +19,13 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { describe, it, beforeEach, afterEach } from 'mocha'; import { expect } from 'chai'; +import sinon, { SinonStubbedInstance } from 'sinon'; import type { AgentPreviewSendResponse } from '@salesforce/agents'; -import { saveTranscriptsToFile } from '../../src/components/agent-preview-react.js'; +import { PlannerResponse } from '@salesforce/agents/lib/types.js'; +import type { Logger } from '@salesforce/core'; +import type { AgentPreviewBase } from '@salesforce/agents'; +import { saveTranscriptsToFile, getTraces } from '../../src/components/agent-preview-react.js'; +import { trace1, trace2 } from '../testData.js'; describe('AgentPreviewReact saveTranscriptsToFile', () => { let testDir: string; @@ -139,4 +144,76 @@ describe('AgentPreviewReact saveTranscriptsToFile', () => { // Should parse as valid JSON expect(() => JSON.parse(content) as unknown).to.not.throw(); }); + + it('should write traces.json when traces are provided', () => { + const outputDir = path.join(testDir, 'output'); + const messages: Array<{ timestamp: Date; role: string; content: string }> = []; + const responses: AgentPreviewSendResponse[] = []; + const traces: PlannerResponse[] = [trace1, trace2]; + + saveTranscriptsToFile(outputDir, messages, responses, traces); + + const tracesPath = path.join(outputDir, 'traces.json'); + expect(fs.existsSync(tracesPath)).to.be.true; + + const content = JSON.parse(fs.readFileSync(tracesPath, 'utf8')) as PlannerResponse[]; + expect(content).to.have.lengthOf(2); + }); +}); + +describe('AgentPreviewReact getTraces', () => { + let mockAgent: SinonStubbedInstance; + let mockLogger: SinonStubbedInstance; + const sessionId = 'session-123'; + const messageIds = ['msg-1', 'msg-2']; + + beforeEach(() => { + mockAgent = { + traces: sinon.stub(), + } as SinonStubbedInstance; + + mockLogger = { + info: sinon.stub(), + } as SinonStubbedInstance; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should return traces when agent.traces succeeds', async () => { + const expectedTraces: PlannerResponse[] = [trace1]; + + mockAgent.traces.resolves(expectedTraces); + + const result = await getTraces(mockAgent, sessionId, messageIds, mockLogger); + + expect(result).to.deep.equal(expectedTraces); + expect(mockAgent.traces.calledWith(sessionId, messageIds)).to.be.true; + expect(mockLogger.info.called).to.be.false; + }); + + it('should return empty array when agent.traces throws an error', async () => { + const error = new Error('Failed to get traces'); + mockAgent.traces.rejects(error); + + const result = await getTraces(mockAgent, sessionId, messageIds, mockLogger); + + expect(result).to.deep.equal([]); + expect(mockAgent.traces.calledWith(sessionId, messageIds)).to.be.true; + expect( + mockLogger.info.calledWith('Error obtaining traces: Error - Failed to get traces', { sessionId, messageIds }) + ).to.be.true; + }); + + it('should handle empty messageIds array', async () => { + const expectedTraces: PlannerResponse[] = []; + mockAgent.traces.resolves(expectedTraces); + + const result = await getTraces(mockAgent, sessionId, [], mockLogger); + + expect(result).to.deep.equal(expectedTraces); + expect(mockAgent.traces.notCalled).to.be.true; + expect(mockLogger.info.called).to.be.false; + }); }); diff --git a/test/testData.ts b/test/testData.ts new file mode 100644 index 0000000..d910afa --- /dev/null +++ b/test/testData.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2025, 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 { PlannerResponse } from '@salesforce/agents/lib/types.js'; + +export const trace1: PlannerResponse = { + type: 'PlanSuccessResponse', + planId: 'plan-1', + sessionId: 'session-123', + intent: 'get_weather', + topic: 'weather', + plan: [ + { + type: 'FunctionStep', + function: { + name: 'get_weather', + input: { location: 'Madrid' }, + output: { temperature: 25, condition: 'sunny' }, + }, + executionLatency: 100, + startExecutionTime: Date.now(), + endExecutionTime: Date.now() + 100, + }, + ], +}; + +export const trace2: PlannerResponse = { + type: 'PlanSuccessResponse', + planId: 'plan-4', + sessionId: 'session-456', + intent: 'send_message', + topic: 'communication', + plan: [ + { + type: 'PlannerResponseStep', + message: 'Hello world', + responseType: 'text', + isContentSafe: true, + safetyScore: { + // eslint-disable-next-line camelcase + safety_score: 0.9, + // eslint-disable-next-line camelcase + category_scores: { + toxicity: 0.1, + hate: 0.0, + identity: 0.0, + violence: 0.0, + physical: 0.0, + sexual: 0.0, + profanity: 0.0, + biased: 0.0, + }, + }, + }, + ], +}; diff --git a/yarn.lock b/yarn.lock index 1972470..7cdd211 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1595,10 +1595,10 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@salesforce/agents@^0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.19.8.tgz#430089ccd3d87d32f2a88e79ccb9afd522b9b4cb" - integrity sha512-aAfaSDevqQrUAFyQgG7FiyltGKG6+ApxpkymTWB2pbQY2SXV7BnNRyO8TwkSS0XAa09HxYW84paOi/Xg3J/9Vw== +"@salesforce/agents@^0.20.0": + version "0.20.0" + resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.20.0.tgz#e4539fb88ee695675890a9942d03cfee189e9db1" + integrity sha512-YiiMEGBuExt1/z5RO2I+rK9X7kn3wkxmAES84L1ELU5OOM1QjvVKj7MqaA+fb8AKugUO43fcAKeJGemdp9/+xw== dependencies: "@salesforce/core" "^8.23.5" "@salesforce/kit" "^3.2.4"