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
35 changes: 5 additions & 30 deletions src/commands/agent/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
import { AuthInfo, Connection, Lifecycle, Messages, SfError } from '@salesforce/core';
import React from 'react';
import { render } from 'ink';
import { env } from '@salesforce/kit';
import {
AgentPreview as Preview,
AgentSimulate,
Expand All @@ -30,7 +29,7 @@ import {
PublishedAgent,
ScriptAgent,
} from '@salesforce/agents';
import { confirm, input, select } from '@inquirer/prompts';
import { select } from '@inquirer/prompts';
import { AgentPreviewReact } from '../../components/agent-preview-react.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
Expand Down Expand Up @@ -176,7 +175,9 @@ export default class AgentPreview extends SfCommand<AgentPreviewResult> {
})
: await Connection.create({ authInfo });

const outputDir = await resolveOutputDir(flags['output-dir'], flags['apex-debug']);
// Only resolve outputDir if explicitly provided via flag
// Otherwise, let user decide when exiting
const outputDir = flags['output-dir'] ? resolve(flags['output-dir']) : undefined;
// Both classes share the same interface for the methods we need
const agentPreview =
selectedAgent.source === AgentSource.PUBLISHED
Expand All @@ -192,6 +193,7 @@ export default class AgentPreview extends SfCommand<AgentPreviewResult> {
name: selectedAgent.DeveloperName,
outputDir,
isLocalAgent: selectedAgent.source === AgentSource.SCRIPT,
apexDebug: flags['apex-debug'],
}),
{ exitOnCtrlC: false }
);
Expand Down Expand Up @@ -258,30 +260,3 @@ export const validateAgent = (agent: AgentData): boolean => {

export const getClientAppsFromAuth = (authInfo: AuthInfo): string[] =>
Object.keys(authInfo.getFields().clientApps ?? {});

export const resolveOutputDir = async (
outputDir: string | undefined,
apexDebug: boolean | undefined
): Promise<string | undefined> => {
if (!outputDir) {
const response = apexDebug
? true
: await confirm({
message: 'Save transcripts to an output directory?',
default: true,
});

const outputTypes = apexDebug ? 'debug logs and transcripts' : 'transcripts';
if (response) {
const getDir = await input({
message: `Enter the output directory for ${outputTypes}`,
default: env.getString('SF_AGENT_PREVIEW_OUTPUT_DIR', join('temp', 'agent-preview')),
required: true,
});

return resolve(getDir);
}
} else {
return resolve(outputDir);
}
};
183 changes: 149 additions & 34 deletions src/components/agent-preview-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@
import path from 'node:path';
import fs from 'node:fs';
import * as process from 'node:process';
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 { AgentPreviewBase, AgentPreviewSendResponse, writeDebugLog } from '@salesforce/agents';
import { sleep } from '@salesforce/kit';
import { sleep, env } from '@salesforce/kit';

// Component to show a simple typing animation
function Typing(): React.ReactNode {
Expand All @@ -48,7 +49,7 @@ function Typing(): React.ReactNode {
);
}

const saveTranscriptsToFile = (
export const saveTranscriptsToFile = (
outputDir: string,
messages: Array<{ timestamp: Date; role: string; content: string }>,
responses: AgentPreviewSendResponse[]
Expand Down Expand Up @@ -76,28 +77,65 @@ export function AgentPreviewReact(props: {
readonly name: string;
readonly outputDir: string | undefined;
readonly isLocalAgent: boolean;
readonly apexDebug: boolean | undefined;
}): React.ReactNode {
const [messages, setMessages] = React.useState<Array<{ timestamp: Date; role: string; content: string }>>([]);
const [header, setHeader] = React.useState('Starting session...');
const [sessionId, setSessionId] = React.useState('');
const [query, setQuery] = React.useState('');
const [isTyping, setIsTyping] = React.useState(true);
const [sessionEnded, setSessionEnded] = React.useState(false);
const [exitRequested, setExitRequested] = React.useState(false);
const [showSavePrompt, setShowSavePrompt] = React.useState(false);
const [showDirInput, setShowDirInput] = React.useState(false);
const [saveDir, setSaveDir] = React.useState('');
const [saveConfirmed, setSaveConfirmed] = React.useState(false);
// @ts-expect-error: Complains if this is not defined but it's not used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [timestamp, setTimestamp] = React.useState(new Date().getTime());
const [tempDir, setTempDir] = React.useState('');
const [responses, setResponses] = React.useState<AgentPreviewSendResponse[]>([]);
const [apexDebugLogs, setApexDebugLogs] = React.useState<string[]>([]);

const { connection, agent, name, outputDir, isLocalAgent } = props;
const { connection, agent, name, outputDir, isLocalAgent, apexDebug } = props;

useInput((input, key) => {
if (key.escape) {
// If user is in directory input and presses ESC, cancel and exit without saving
if (showDirInput && (key.escape || (key.ctrl && input === 'c'))) {
setSessionEnded(true);
return;
}
if (key.ctrl && input === 'c') {
setSessionEnded(true);

// Only handle exit if we're not already in save prompt flow
if (!exitRequested && !showSavePrompt && !showDirInput) {
if (key.escape || (key.ctrl && input === 'c')) {
setExitRequested(true);
setShowSavePrompt(true);
}
return;
}

// Handle save prompt navigation
if (showSavePrompt && !showDirInput) {
if (input.toLowerCase() === 'y' || input.toLowerCase() === 'n') {
if (input.toLowerCase() === 'y') {
// If outputDir was provided via flag, use it directly
if (outputDir) {
setSaveDir(outputDir);
setSaveConfirmed(true);
setShowSavePrompt(false);
} else {
// Otherwise, prompt for directory
setShowSavePrompt(false);
setShowDirInput(true);
const defaultDir = env.getString('SF_AGENT_PREVIEW_OUTPUT_DIR', path.join('temp', 'agent-preview'));
setSaveDir(defaultDir);
}
} else {
// User said no, exit without saving
setSessionEnded(true);
}
}
}
});

Expand All @@ -115,7 +153,7 @@ export function AgentPreviewReact(props: {
}
};
void endSession();
}, [sessionEnded]);
}, [sessionEnded, sessionId, agent]);

React.useEffect(() => {
// Set up event listeners for agent compilation and simulation events
Expand Down Expand Up @@ -148,16 +186,13 @@ export function AgentPreviewReact(props: {
setHeader(`New session started with "${props.name}" (${session.sessionId})`);
await sleep(500); // Add a short delay to make it feel more natural
setIsTyping(false);
if (outputDir) {
const dateForDir = new Date().toISOString().replace(/:/g, '-').split('.')[0];
setTempDir(path.join(outputDir, `${dateForDir}--${session.sessionId}`));
}
// Add disclaimer for local agents before the agent's first message
const initialMessages = [];
if (isLocalAgent) {
initialMessages.push({
role: 'system',
content: 'Agent preview does not provide strict adherence to connection endpoint configuration and escalation is not supported.\n\nTo test escalation, publish your agent then use the desired connection endpoint (e.g., Web Page, SMS, etc).',
content:
'Agent preview does not provide strict adherence to connection endpoint configuration and escalation is not supported.\n\nTo test escalation, publish your agent then use the desired connection endpoint (e.g., Web Page, SMS, etc).',
timestamp: new Date(),
});
}
Expand All @@ -176,9 +211,50 @@ export function AgentPreviewReact(props: {
}, [agent, name, outputDir, props.name, isLocalAgent]);

React.useEffect(() => {
saveTranscriptsToFile(tempDir, messages, responses);
// Save to tempDir if it was set (during session)
if (tempDir) {
saveTranscriptsToFile(tempDir, messages, responses);
}
}, [tempDir, messages, responses]);

// Handle saving when user confirms save on exit
React.useEffect(() => {
const saveAndExit = async (): Promise<void> => {
if (saveConfirmed && saveDir) {
const finalDir = resolve(saveDir);
fs.mkdirSync(finalDir, { recursive: true });

// Create a timestamped subdirectory for this session
const dateForDir = new Date().toISOString().replace(/:/g, '-').split('.')[0];
const sessionDir = path.join(finalDir, `${dateForDir}--${sessionId || 'session'}`);
fs.mkdirSync(sessionDir, { recursive: true });

saveTranscriptsToFile(sessionDir, messages, responses);

// Write apex debug logs if any
if (apexDebug) {
for (const response of responses) {
if (response.apexDebugLog) {
// eslint-disable-next-line no-await-in-loop
await writeDebugLog(connection, response.apexDebugLog, sessionDir);
const logId = response.apexDebugLog.Id;
if (logId) {
setApexDebugLogs((prev) => [...prev, path.join(sessionDir, `${logId}.log`)]);
}
}
}
}

// Update tempDir so the save message shows the correct path
setTempDir(sessionDir);

// Mark session as ended to trigger exit
setSessionEnded(true);
}
};
void saveAndExit();
}, [saveConfirmed, saveDir, messages, responses, sessionId, apexDebug, connection]);

return (
<Box flexDirection="column">
<Box
Expand Down Expand Up @@ -218,11 +294,7 @@ export function AgentPreviewReact(props: {
<Text>{role === 'user' ? 'You' : role}</Text>
<Text color="grey">{ts.toLocaleString()}</Text>
</Box>
<Box
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box borderStyle="round" paddingLeft={1} paddingRight={1}>
<Text>{content}</Text>
</Box>
</>
Expand Down Expand Up @@ -251,13 +323,64 @@ export function AgentPreviewReact(props: {
<Text dimColor>{'─'.repeat(process.stdout.columns - 2)}</Text>
</Box>

{sessionEnded ? null : (
{showSavePrompt && !showDirInput ? (
<Box
flexDirection="column"
width={process.stdout.columns}
borderStyle="round"
borderColor="yellow"
marginTop={1}
marginBottom={1}
paddingLeft={1}
paddingRight={1}
>
<Text bold>Save chat history before exiting? (y/n)</Text>
{outputDir ? (
<Text dimColor>Will save to: {outputDir}</Text>
) : (
<Text dimColor>Press &#39;y&#39; to save, &#39;n&#39; to exit without saving</Text>
)}
</Box>
) : null}

{showDirInput ? (
<Box
flexDirection="column"
width={process.stdout.columns}
borderStyle="round"
borderColor="yellow"
marginTop={1}
marginBottom={1}
paddingLeft={1}
paddingRight={1}
>
<Text bold>Enter output directory for {apexDebug ? 'debug logs and transcripts' : 'transcripts'}:</Text>
<Box marginTop={1}>
<Text>&gt; </Text>
<TextInput
showCursor
value={saveDir}
placeholder="Press Enter to confirm"
onChange={setSaveDir}
onSubmit={(dir) => {
if (dir) {
setSaveDir(dir);
setSaveConfirmed(true);
setShowDirInput(false);
}
}}
/>
</Box>
</Box>
) : null}

{!sessionEnded && !exitRequested && !showSavePrompt && !showDirInput ? (
<Box marginBottom={1}>
<Text>&gt; </Text>
<TextInput
showCursor
value={query}
placeholder="Start typing (press ESC to exit)"
placeholder="Start typing (press ESC or Ctrl+C to exit)"
onChange={setQuery}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={async (content) => {
Expand All @@ -280,15 +403,7 @@ export function AgentPreviewReact(props: {
// Add the agent's response to the chat
setMessages((prev) => [...prev, { role: name, content: message, timestamp: new Date() }]);

// If there is an apex debug log entry, get the log and write it to the output dir
if (response.apexDebugLog && tempDir) {
// Write the apex debug to the output dir
await writeDebugLog(connection, response.apexDebugLog, tempDir);
const logId = response.apexDebugLog.Id;
if (logId) {
setApexDebugLogs((prev) => [...prev, path.join(tempDir, `${logId}.log`)]);
}
}
// Apex debug logs will be saved when user exits and chooses to save
} catch (e) {
const sfError = SfError.wrap(e);
setIsTyping(false);
Expand All @@ -299,9 +414,9 @@ export function AgentPreviewReact(props: {
}}
/>
</Box>
)}
) : null}

{sessionEnded ? (
{sessionEnded && !showSavePrompt && !showDirInput ? (
<Box
flexDirection="column"
width={process.stdout.columns}
Expand All @@ -312,9 +427,9 @@ export function AgentPreviewReact(props: {
paddingRight={1}
>
<Text bold>Session Ended</Text>
{outputDir ? <Text>Conversation log: {tempDir}/transcript.json</Text> : null}
{outputDir ? <Text>API transactions: {tempDir}/responses.json</Text> : null}
{apexDebugLogs.length > 0 && <Text>Apex Debug Logs: {'\n' + apexDebugLogs.join('\n')}</Text>}
{tempDir ? <Text>Conversation log: {tempDir}/transcript.json</Text> : null}
{tempDir ? <Text>API transactions: {tempDir}/responses.json</Text> : null}
{apexDebugLogs.length > 0 && tempDir && <Text>Apex Debug Logs saved to: {tempDir}</Text>}
</Box>
) : null}
</Box>
Expand Down
Loading
Loading