Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion src/commands/agent/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = {
Expand Down Expand Up @@ -195,6 +203,7 @@ export default class AgentPreview extends SfCommand<AgentPreviewResult> {
outputDir,
isLocalAgent: selectedAgent.source === AgentSource.SCRIPT,
apexDebug: flags['apex-debug'],
logger: getLogger(),
}),
{ exitOnCtrlC: false }
);
Expand Down
41 changes: 36 additions & 5 deletions src/components/agent-preview-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
Expand All @@ -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<PlannerResponse[]> => {
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 [];
};

/**
Expand All @@ -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<Array<{ timestamp: Date; role: string; content: string }>>([]);
const [header, setHeader] = React.useState('Starting session...');
Expand All @@ -96,8 +122,9 @@ export function AgentPreviewReact(props: {
const [tempDir, setTempDir] = React.useState('');
const [responses, setResponses] = React.useState<AgentPreviewSendResponse[]>([]);
const [apexDebugLogs, setApexDebugLogs] = React.useState<string[]>([]);
const [messageIds, setMessageIds] = React.useState<string[]>([]);

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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 (
<Box flexDirection="column">
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -422,6 +452,7 @@ export function AgentPreviewReact(props: {
<Text bold>Session Ended</Text>
{tempDir ? <Text>Conversation log: {tempDir}/transcript.json</Text> : null}
{tempDir ? <Text>API transactions: {tempDir}/responses.json</Text> : null}
{tempDir ? <Text>Traces: {tempDir}/traces.json</Text> : null}
{apexDebugLogs.length > 0 && tempDir && <Text>Apex Debug Logs saved to: {tempDir}</Text>}
</Box>
) : null}
Expand Down
79 changes: 78 additions & 1 deletion test/components/agent-preview-react.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AgentPreviewBase>;
let mockLogger: SinonStubbedInstance<Logger>;
const sessionId = 'session-123';
const messageIds = ['msg-1', 'msg-2'];

beforeEach(() => {
mockAgent = {
traces: sinon.stub(),
} as SinonStubbedInstance<AgentPreviewBase>;

mockLogger = {
info: sinon.stub(),
} as SinonStubbedInstance<Logger>;
});

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;
});
});
68 changes: 68 additions & 0 deletions test/testData.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
},
],
};
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading