Skip to content

Commit 694ca7e

Browse files
Merge pull request #290 from salesforcecli/er/saveTraces
W-20269100: save traces to file
2 parents a896b2e + 54e4331 commit 694ca7e

File tree

6 files changed

+197
-12
lines changed

6 files changed

+197
-12
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"@inquirer/prompts": "^7.10.1",
1010
"@oclif/core": "^4",
1111
"@oclif/multi-stage-output": "^0.8.29",
12-
"@salesforce/agents": "^0.19.8",
12+
"@salesforce/agents": "^0.20.0",
1313
"@salesforce/core": "^8.23.7",
1414
"@salesforce/kit": "^3.2.3",
1515
"@salesforce/sf-plugins-core": "^12.2.6",

src/commands/agent/preview.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import * as path from 'node:path';
1818
import { join, resolve } from 'node:path';
1919
import { globSync } from 'glob';
2020
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
21-
import { AuthInfo, Connection, Lifecycle, Messages, SfError } from '@salesforce/core';
21+
import { AuthInfo, Connection, Lifecycle, Logger, Messages, SfError } from '@salesforce/core';
2222
import React from 'react';
2323
import { render } from 'ink';
2424
import {
@@ -35,6 +35,14 @@ import { AgentPreviewReact } from '../../components/agent-preview-react.js';
3535
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
3636
const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview');
3737

38+
let logger: Logger;
39+
const getLogger = (): Logger => {
40+
if (!logger) {
41+
logger = Logger.childFromRoot('plugin-agent-preview');
42+
}
43+
return logger;
44+
};
45+
3846
type BotVersionStatus = { Status: 'Active' | 'Inactive' };
3947

4048
export type AgentData = {
@@ -195,6 +203,7 @@ export default class AgentPreview extends SfCommand<AgentPreviewResult> {
195203
outputDir,
196204
isLocalAgent: selectedAgent.source === AgentSource.SCRIPT,
197205
apexDebug: flags['apex-debug'],
206+
logger: getLogger(),
198207
}),
199208
{ exitOnCtrlC: false }
200209
);

src/components/agent-preview-react.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import { resolve } from 'node:path';
2121
import React from 'react';
2222
import { Box, Text, useInput } from 'ink';
2323
import TextInput from 'ink-text-input';
24-
import { Connection, SfError, Lifecycle } from '@salesforce/core';
24+
import { Connection, SfError, Lifecycle, Logger } from '@salesforce/core';
2525
import { AgentPreviewBase, AgentPreviewSendResponse, writeDebugLog } from '@salesforce/agents';
2626
import { sleep, env } from '@salesforce/kit';
27+
import { PlannerResponse } from '@salesforce/agents/lib/types.js';
2728

2829
// Component to show a simple typing animation
2930
function Typing(): React.ReactNode {
@@ -52,7 +53,8 @@ function Typing(): React.ReactNode {
5253
export const saveTranscriptsToFile = (
5354
outputDir: string,
5455
messages: Array<{ timestamp: Date; role: string; content: string }>,
55-
responses: AgentPreviewSendResponse[]
56+
responses: AgentPreviewSendResponse[],
57+
traces?: PlannerResponse[]
5658
): void => {
5759
if (!outputDir) return;
5860
fs.mkdirSync(outputDir, { recursive: true });
@@ -62,6 +64,29 @@ export const saveTranscriptsToFile = (
6264

6365
const responsesPath = path.join(outputDir, 'responses.json');
6466
fs.writeFileSync(responsesPath, JSON.stringify(responses, null, 2));
67+
68+
if (traces) {
69+
const tracesPath = path.join(outputDir, 'traces.json');
70+
fs.writeFileSync(tracesPath, JSON.stringify(traces, null, 2));
71+
}
72+
};
73+
74+
export const getTraces = async (
75+
agent: AgentPreviewBase,
76+
sessionId: string,
77+
messageIds: string[],
78+
logger: Logger
79+
): Promise<PlannerResponse[]> => {
80+
if (messageIds.length > 0) {
81+
try {
82+
const traces = await agent.traces(sessionId, messageIds);
83+
return traces;
84+
} catch (e) {
85+
const sfError = SfError.wrap(e);
86+
logger.info(`Error obtaining traces: ${sfError.name} - ${sfError.message}`, { sessionId, messageIds });
87+
}
88+
}
89+
return [];
6590
};
6691

6792
/**
@@ -78,6 +103,7 @@ export function AgentPreviewReact(props: {
78103
readonly outputDir: string | undefined;
79104
readonly isLocalAgent: boolean;
80105
readonly apexDebug: boolean | undefined;
106+
readonly logger: Logger;
81107
}): React.ReactNode {
82108
const [messages, setMessages] = React.useState<Array<{ timestamp: Date; role: string; content: string }>>([]);
83109
const [header, setHeader] = React.useState('Starting session...');
@@ -96,8 +122,9 @@ export function AgentPreviewReact(props: {
96122
const [tempDir, setTempDir] = React.useState('');
97123
const [responses, setResponses] = React.useState<AgentPreviewSendResponse[]>([]);
98124
const [apexDebugLogs, setApexDebugLogs] = React.useState<string[]>([]);
125+
const [messageIds, setMessageIds] = React.useState<string[]>([]);
99126

100-
const { connection, agent, name, outputDir, isLocalAgent, apexDebug } = props;
127+
const { connection, agent, name, outputDir, isLocalAgent, apexDebug, logger } = props;
101128

102129
useInput((input, key) => {
103130
// If user is in directory input and presses ESC, cancel and exit without saving
@@ -222,7 +249,9 @@ export function AgentPreviewReact(props: {
222249
const sessionDir = path.join(finalDir, `${dateForDir}--${sessionId || 'session'}`);
223250
fs.mkdirSync(sessionDir, { recursive: true });
224251

225-
saveTranscriptsToFile(sessionDir, messages, responses);
252+
const traces = await getTraces(agent, sessionId, messageIds, logger);
253+
254+
saveTranscriptsToFile(sessionDir, messages, responses, traces);
226255

227256
// Write apex debug logs if any
228257
if (apexDebug) {
@@ -246,7 +275,7 @@ export function AgentPreviewReact(props: {
246275
}
247276
};
248277
void saveAndExit();
249-
}, [saveConfirmed, saveDir, messages, responses, sessionId, apexDebug, connection]);
278+
}, [saveConfirmed, saveDir, messages, responses, sessionId, apexDebug, connection, agent, messageIds, logger]);
250279

251280
return (
252281
<Box flexDirection="column">
@@ -395,6 +424,7 @@ export function AgentPreviewReact(props: {
395424

396425
// Add the agent's response to the chat
397426
setMessages((prev) => [...prev, { role: name, content: message, timestamp: new Date() }]);
427+
setMessageIds((prev) => [...prev, response.messages[0].planId]);
398428

399429
// Apex debug logs will be saved when user exits and chooses to save
400430
} catch (e) {
@@ -422,6 +452,7 @@ export function AgentPreviewReact(props: {
422452
<Text bold>Session Ended</Text>
423453
{tempDir ? <Text>Conversation log: {tempDir}/transcript.json</Text> : null}
424454
{tempDir ? <Text>API transactions: {tempDir}/responses.json</Text> : null}
455+
{tempDir ? <Text>Traces: {tempDir}/traces.json</Text> : null}
425456
{apexDebugLogs.length > 0 && tempDir && <Text>Apex Debug Logs saved to: {tempDir}</Text>}
426457
</Box>
427458
) : null}

test/components/agent-preview-react.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@ import * as os from 'node:os';
1919
import * as path from 'node:path';
2020
import { describe, it, beforeEach, afterEach } from 'mocha';
2121
import { expect } from 'chai';
22+
import sinon, { SinonStubbedInstance } from 'sinon';
2223
import type { AgentPreviewSendResponse } from '@salesforce/agents';
23-
import { saveTranscriptsToFile } from '../../src/components/agent-preview-react.js';
24+
import { PlannerResponse } from '@salesforce/agents/lib/types.js';
25+
import type { Logger } from '@salesforce/core';
26+
import type { AgentPreviewBase } from '@salesforce/agents';
27+
import { saveTranscriptsToFile, getTraces } from '../../src/components/agent-preview-react.js';
28+
import { trace1, trace2 } from '../testData.js';
2429

2530
describe('AgentPreviewReact saveTranscriptsToFile', () => {
2631
let testDir: string;
@@ -139,4 +144,76 @@ describe('AgentPreviewReact saveTranscriptsToFile', () => {
139144
// Should parse as valid JSON
140145
expect(() => JSON.parse(content) as unknown).to.not.throw();
141146
});
147+
148+
it('should write traces.json when traces are provided', () => {
149+
const outputDir = path.join(testDir, 'output');
150+
const messages: Array<{ timestamp: Date; role: string; content: string }> = [];
151+
const responses: AgentPreviewSendResponse[] = [];
152+
const traces: PlannerResponse[] = [trace1, trace2];
153+
154+
saveTranscriptsToFile(outputDir, messages, responses, traces);
155+
156+
const tracesPath = path.join(outputDir, 'traces.json');
157+
expect(fs.existsSync(tracesPath)).to.be.true;
158+
159+
const content = JSON.parse(fs.readFileSync(tracesPath, 'utf8')) as PlannerResponse[];
160+
expect(content).to.have.lengthOf(2);
161+
});
162+
});
163+
164+
describe('AgentPreviewReact getTraces', () => {
165+
let mockAgent: SinonStubbedInstance<AgentPreviewBase>;
166+
let mockLogger: SinonStubbedInstance<Logger>;
167+
const sessionId = 'session-123';
168+
const messageIds = ['msg-1', 'msg-2'];
169+
170+
beforeEach(() => {
171+
mockAgent = {
172+
traces: sinon.stub(),
173+
} as SinonStubbedInstance<AgentPreviewBase>;
174+
175+
mockLogger = {
176+
info: sinon.stub(),
177+
} as SinonStubbedInstance<Logger>;
178+
});
179+
180+
afterEach(() => {
181+
sinon.restore();
182+
});
183+
184+
it('should return traces when agent.traces succeeds', async () => {
185+
const expectedTraces: PlannerResponse[] = [trace1];
186+
187+
mockAgent.traces.resolves(expectedTraces);
188+
189+
const result = await getTraces(mockAgent, sessionId, messageIds, mockLogger);
190+
191+
expect(result).to.deep.equal(expectedTraces);
192+
expect(mockAgent.traces.calledWith(sessionId, messageIds)).to.be.true;
193+
expect(mockLogger.info.called).to.be.false;
194+
});
195+
196+
it('should return empty array when agent.traces throws an error', async () => {
197+
const error = new Error('Failed to get traces');
198+
mockAgent.traces.rejects(error);
199+
200+
const result = await getTraces(mockAgent, sessionId, messageIds, mockLogger);
201+
202+
expect(result).to.deep.equal([]);
203+
expect(mockAgent.traces.calledWith(sessionId, messageIds)).to.be.true;
204+
expect(
205+
mockLogger.info.calledWith('Error obtaining traces: Error - Failed to get traces', { sessionId, messageIds })
206+
).to.be.true;
207+
});
208+
209+
it('should handle empty messageIds array', async () => {
210+
const expectedTraces: PlannerResponse[] = [];
211+
mockAgent.traces.resolves(expectedTraces);
212+
213+
const result = await getTraces(mockAgent, sessionId, [], mockLogger);
214+
215+
expect(result).to.deep.equal(expectedTraces);
216+
expect(mockAgent.traces.notCalled).to.be.true;
217+
expect(mockLogger.info.called).to.be.false;
218+
});
142219
});

test/testData.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2025, Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { PlannerResponse } from '@salesforce/agents/lib/types.js';
17+
18+
export const trace1: PlannerResponse = {
19+
type: 'PlanSuccessResponse',
20+
planId: 'plan-1',
21+
sessionId: 'session-123',
22+
intent: 'get_weather',
23+
topic: 'weather',
24+
plan: [
25+
{
26+
type: 'FunctionStep',
27+
function: {
28+
name: 'get_weather',
29+
input: { location: 'Madrid' },
30+
output: { temperature: 25, condition: 'sunny' },
31+
},
32+
executionLatency: 100,
33+
startExecutionTime: Date.now(),
34+
endExecutionTime: Date.now() + 100,
35+
},
36+
],
37+
};
38+
39+
export const trace2: PlannerResponse = {
40+
type: 'PlanSuccessResponse',
41+
planId: 'plan-4',
42+
sessionId: 'session-456',
43+
intent: 'send_message',
44+
topic: 'communication',
45+
plan: [
46+
{
47+
type: 'PlannerResponseStep',
48+
message: 'Hello world',
49+
responseType: 'text',
50+
isContentSafe: true,
51+
safetyScore: {
52+
// eslint-disable-next-line camelcase
53+
safety_score: 0.9,
54+
// eslint-disable-next-line camelcase
55+
category_scores: {
56+
toxicity: 0.1,
57+
hate: 0.0,
58+
identity: 0.0,
59+
violence: 0.0,
60+
physical: 0.0,
61+
sexual: 0.0,
62+
profanity: 0.0,
63+
biased: 0.0,
64+
},
65+
},
66+
},
67+
],
68+
};

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,10 +1595,10 @@
15951595
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
15961596
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==
15971597

1598-
"@salesforce/agents@^0.19.8":
1599-
version "0.19.8"
1600-
resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.19.8.tgz#430089ccd3d87d32f2a88e79ccb9afd522b9b4cb"
1601-
integrity sha512-aAfaSDevqQrUAFyQgG7FiyltGKG6+ApxpkymTWB2pbQY2SXV7BnNRyO8TwkSS0XAa09HxYW84paOi/Xg3J/9Vw==
1598+
"@salesforce/agents@^0.20.0":
1599+
version "0.20.0"
1600+
resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.20.0.tgz#e4539fb88ee695675890a9942d03cfee189e9db1"
1601+
integrity sha512-YiiMEGBuExt1/z5RO2I+rK9X7kn3wkxmAES84L1ELU5OOM1QjvVKj7MqaA+fb8AKugUO43fcAKeJGemdp9/+xw==
16021602
dependencies:
16031603
"@salesforce/core" "^8.23.5"
16041604
"@salesforce/kit" "^3.2.4"

0 commit comments

Comments
 (0)