diff --git a/package.json b/package.json index 0fa07ae7..57dcf62f 100644 --- a/package.json +++ b/package.json @@ -180,47 +180,47 @@ "view/title": [ { "command": "sf.agent.combined.view.exportConversation", - "when": "view == sf.agent.combined.view && agentforceDX:agentSelected && !agentforceDX:sessionStarting && !agentforceDX:sessionError && agentforceDX:hasConversationData", - "group": "navigation@-1" - }, - { - "command": "salesforcedx-vscode-agents.createAiAuthoringBundle", - "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:canResetAgentView && !agentforceDX:agentSelected", - "group": "navigation@-1" + "when": "view == sf.agent.combined.view && agentforceDX:agentSelected && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:sessionError && agentforceDX:hasConversationData", + "group": "navigation@3" }, { "command": "salesforcedx-vscode-agents.activateAgent", - "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:canResetAgentView && !agentforceDX:agentSelected", - "group": "navigation@0" + "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:canResetAgentView && !agentforceDX:agentSelected", + "group": "navigation@1" }, { "command": "sf.agent.combined.view.activateVersion", - "when": "view == sf.agent.combined.view && agentforceDX:agentSelected && !agentforceDX:isScriptAgent && !agentforceDX:sessionActive && !agentforceDX:sessionStarting", - "group": "navigation@0" + "when": "view == sf.agent.combined.view && agentforceDX:agentSelected && !agentforceDX:isScriptAgent && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:sessionError", + "group": "navigation@2" + }, + { + "command": "sf.agent.combined.view.clearLoadedSession", + "when": "view == sf.agent.combined.view && agentforceDX:hasLoadedSession && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:canResetAgentView && !agentforceDX:sessionError", + "group": "navigation@4" }, { "command": "sf.agent.combined.view.refreshAgents", - "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:canResetAgentView", - "group": "navigation@1" + "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:canResetAgentView", + "group": "navigation@0" }, { "command": "sf.agent.combined.view.resetAgentView", - "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && agentforceDX:agentSelected && agentforceDX:canResetAgentView", + "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:agentSelected && agentforceDX:canResetAgentView", "group": "navigation@0" }, { "submenu": "sf.agent.combined.view.restartMenu", - "when": "view == sf.agent.combined.view && agentforceDX:sessionActive && !agentforceDX:sessionStarting && agentforceDX:agentSelected && agentforceDX:isScriptAgent", - "group": "navigation@0" + "when": "view == sf.agent.combined.view && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:agentSelected && agentforceDX:isScriptAgent", + "group": "navigation@-3" }, { "command": "sf.agent.combined.view.debug", - "when": "view == sf.agent.combined.view && agentforceDX:sessionActive && !agentforceDX:debugMode && agentforceDX:isLiveMode", + "when": "view == sf.agent.combined.view && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:debugMode && agentforceDX:isLiveMode", "group": "navigation@-2" }, { "command": "sf.agent.combined.view.debugStop", - "when": "view == sf.agent.combined.view && agentforceDX:sessionActive && agentforceDX:debugMode && agentforceDX:isLiveMode", + "when": "view == sf.agent.combined.view && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:debugMode && agentforceDX:isLiveMode", "group": "navigation@-2" }, { @@ -465,6 +465,11 @@ "title": "Refresh Agent List", "icon": "$(refresh)" }, + { + "command": "sf.agent.combined.view.clearLoadedSession", + "title": "Clear Chat Session", + "icon": "$(clear-all)" + }, { "command": "sf.agent.combined.view.resetAgentView", "title": "Reset", diff --git a/src/extension.ts b/src/extension.ts index 5105f59e..607ff4a3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -367,6 +367,12 @@ const registerAgentCombinedView = (context: vscode.ExtensionContext): vscode.Dis }) ); + disposables.push( + vscode.commands.registerCommand('sf.agent.combined.view.clearLoadedSession', () => { + provider.clearLoadedSession(); + }) + ); + disposables.push( vscode.commands.registerCommand('sf.agent.combined.view.resetAgentView', async () => { const currentAgentId = provider.getCurrentAgentId(); diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index e08f2a1b..2a3d917d 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -11,6 +11,7 @@ import type { HistoryManager } from '../history'; import type { ApexDebugManager } from '../debugging'; import { Logger } from '../../../utils/logger'; import { getAgentSource } from '../agent'; +import { listSessionsForAgent } from '../session'; /** * Handles all incoming messages from the webview @@ -44,7 +45,7 @@ export class WebviewMessageHandlers { startSession: async msg => await this.handleStartSession(msg), setApexDebugging: async msg => await this.handleSetApexDebugging(msg), sendChatMessage: async msg => await this.handleSendChatMessage(msg), - endSession: async () => await this.handleEndSession(), + endSession: async msg => await this.handleEndSession(msg), loadAgentHistory: async msg => await this.handleLoadAgentHistory(msg), getAvailableAgents: async () => await this.handleGetAvailableAgents(), getTraceData: async () => await this.handleGetTraceData(), @@ -54,6 +55,9 @@ export class WebviewMessageHandlers { setSelectedAgentId: async msg => await this.handleSetSelectedAgentId(msg), setLiveMode: async msg => await this.handleSetLiveMode(msg), getInitialLiveMode: async () => await this.handleGetInitialLiveMode(), + listSessions: async msg => await this.handleListSessions(msg), + previewSession: async msg => await this.handlePreviewSession(msg), + clearPreviewedSession: async () => await this.handleClearPreviewedSession(), // Test-specific commands for integration tests clearMessages: async () => { // Clear messages in the webview - no-op on extension side @@ -136,6 +140,16 @@ export class WebviewMessageHandlers { this.state.currentAgentSource = agentSource; const isLiveMode = data?.isLiveMode ?? false; + + // If a session is currently being previewed via the History tab, the Start + // button should resume that session rather than create a new one. + const previewedSessionId = this.state.previewedSessionId; + if (previewedSessionId && agentId === this.state.currentAgentId) { + this.state.previewedSessionId = undefined; + await this.sessionManager.resumeSession(agentId, agentSource, previewedSessionId, isLiveMode, this.webviewView); + return; + } + await this.sessionManager.startSession(agentId, agentSource, isLiveMode, this.webviewView); } @@ -214,14 +228,18 @@ export class WebviewMessageHandlers { } } - private async handleEndSession(): Promise { - await this.sessionManager.endSession(async () => { - const agentId = this.state.pendingStartAgentId ?? this.state.currentAgentId; - if (agentId) { - const agentSource = this.state.pendingStartAgentSource ?? (await getAgentSource(agentId)); - await this.historyManager.showHistoryOrPlaceholder(agentId, agentSource); - } - }); + private async handleEndSession(message?: AgentMessage): Promise { + const data = message?.data as { restarting?: boolean } | undefined; + await this.sessionManager.endSession( + async () => { + const agentId = this.state.pendingStartAgentId ?? this.state.currentAgentId; + if (agentId) { + const agentSource = this.state.pendingStartAgentSource ?? (await getAgentSource(agentId)); + await this.historyManager.showHistoryOrPlaceholder(agentId, agentSource); + } + }, + { restarting: data?.restarting === true } + ); } private async handleLoadAgentHistory(message: AgentMessage): Promise { @@ -312,6 +330,17 @@ export class WebviewMessageHandlers { private async handleGetTraceData(): Promise { try { if (this.state.currentAgentId && this.state.currentAgentSource) { + // When the user is previewing a specific historical session, load + // traces for that session (not falling back to "most recent"). Use + // the trace-only path so we don't re-render the chat. + if (this.state.previewedSessionId && !this.state.isSessionActive) { + await this.historyManager.loadAndSendTracesForSession( + this.state.currentAgentId, + this.state.currentAgentSource, + this.state.previewedSessionId + ); + return; + } await this.historyManager.loadAndSendTraceHistory(this.state.currentAgentId, this.state.currentAgentSource); return; } @@ -351,6 +380,9 @@ export class WebviewMessageHandlers { private async handleSetSelectedAgentId(message: AgentMessage): Promise { const data = message.data as { agentId?: string; agentSource?: AgentSource } | undefined; const agentId = data?.agentId; + if (agentId !== this.state.currentAgentId) { + this.state.previewedSessionId = undefined; + } if (agentId && typeof agentId === 'string' && agentId !== '') { this.state.currentAgentId = agentId; // Use passed agentSource if available to avoid expensive listPreviewable call @@ -381,6 +413,147 @@ export class WebviewMessageHandlers { this.messageSender.sendLiveMode(this.state.isLiveMode); } + private async handleListSessions(message: AgentMessage): Promise { + const data = message.data as { agentId?: string; agentSource?: AgentSource } | undefined; + const agentId = data?.agentId ?? this.state.currentAgentId; + if (!agentId || typeof agentId !== 'string') { + this.messageSender.sendSessionList('', []); + return; + } + try { + const agentSource = data?.agentSource ?? this.state.currentAgentSource ?? (await getAgentSource(agentId)); + const sessions = await listSessionsForAgent(agentId, agentSource); + this.messageSender.sendSessionList(agentId, sessions); + } catch (err) { + console.error('Error listing sessions:', err); + this.messageSender.sendSessionList(agentId, []); + } + } + + /** + * Loads a prior session's transcript + traces into the views without starting it. + * Records previewedSessionId so the next "start" click resumes this session + * instead of creating a new one. + */ + private async handlePreviewSession(message: AgentMessage): Promise { + const data = message.data as + | { agentId?: string; agentSource?: AgentSource; sessionId?: string; sessionType?: 'simulated' | 'live' | 'published' } + | undefined; + const agentId = data?.agentId ?? this.state.currentAgentId; + const sessionId = data?.sessionId; + + if (!agentId || typeof agentId !== 'string') { + throw new Error(`Invalid agent ID: ${agentId}. Expected a string.`); + } + if (!sessionId || typeof sessionId !== 'string') { + throw new Error(`Invalid session ID: ${sessionId}. Expected a string.`); + } + + // No-op if this session is already the active live session. + if ( + this.state.isSessionActive && + this.state.sessionId === sessionId && + this.state.sessionAgentId === agentId + ) { + return; + } + + let agentSource = data?.agentSource ?? this.state.currentAgentSource; + if (!agentSource) { + agentSource = await getAgentSource(agentId); + } + + // Capture prior session identity BEFORE overwriting currentAgentSource. + // Without this, previousSource reads the new source and the wrong + // preview.end() argument is used to tear down the running session when + // the previewed session has a different source than the running one. + const previousAgent = this.state.agentInstance; + const previousSessionId = this.state.sessionId; + const previousSource = this.state.currentAgentSource; + const hadActiveSession = !!(previousAgent && previousSessionId); + + this.state.currentAgentSource = agentSource; + this.state.currentAgentId = agentId; + + // If a session is active, fully end it before showing the previewed + // conversation. We surface a loading state via sessionStarting so the input + // is disabled while the SDK round-trip completes (a few seconds for + // live/published sessions). + if (hadActiveSession) { + this.state.cancelPendingSessionStart(); + // Flip context flags immediately so toolbar actions tied to sessionActive + // (debug, stop, etc.) hide right away during the stopping transition. + // The SDK round-trip below can take seconds and we don't want stale + // session-active toolbar buttons hanging around. + await this.state.setSessionActive(false); + await this.state.setSessionStarting(true); + await this.state.setSessionStopping(true); + // Send sessionStarting FIRST so the webview's isSessionStartingRef flips + // to true before the empty setConversation arrives — App.tsx uses that + // ref to distinguish a stopping-transition clear (preserve Resume label) + // from a toolbar Clear action (drop preview flag). + this.messageSender.sendSessionStarting('Stopping session...'); + this.messageSender.sendSetConversation([], true, null); + this.messageSender.sendTraceHistory(agentId, []); + try { + if (previousSource === AgentSource.SCRIPT) { + await previousAgent!.preview.end(); + } else { + await previousAgent!.preview.end('UserRequest'); + } + } catch (err) { + console.warn('Error ending previous session before preview:', err); + } + try { + await previousAgent!.restoreConnection(); + } catch (err) { + console.warn('Error restoring connection:', err); + } + this.state.clearSessionState(); + // We deliberately keep isSessionStarting=true through the disk read below + // so the input stays disabled. Cleared after the preview is loaded. + + // Re-establish current agent context cleared by clearSessionState so the + // previewed conversation is associated correctly. + this.state.currentAgentId = agentId; + this.state.currentAgentSource = agentSource; + } else if (this.state.isSessionStarting) { + // Cancel any in-flight start that hasn't produced an agent instance yet. + await this.sessionManager.endSession(); + } + + this.state.previewedSessionId = sessionId; + + // Read the previewed session from disk and push it to the webview. The + // setConversation message includes previewSessionInfo so the start button + // flips to "Resume". + await this.historyManager.loadAndSendSessionPreview(agentId, agentSource, sessionId, data?.sessionType); + + if (hadActiveSession) { + // Now that the previewed conversation is on screen, clear the + // starting/active state and emit sessionEnded so the input becomes + // editable and the start button shows "Resume". + await this.state.setSessionStarting(false); + await this.state.setSessionStopping(false); + this.messageSender.sendSessionEnded(); + } + } + + /** + * Drops the currently displayed conversation/traces so the user can start a + * fresh session from an empty chat. Does not touch on-disk session data. + */ + private async handleClearPreviewedSession(): Promise { + this.state.previewedSessionId = undefined; + this.state.currentPlanId = undefined; + await this.state.setConversationDataAvailable(false); + this.messageSender.sendSetConversation([], true, null); + if (this.state.currentAgentId) { + this.messageSender.sendTraceHistory(this.state.currentAgentId, []); + } + this.messageSender.sendTraceData({ plan: [], planId: '', sessionId: '' }); + } + async fetchAndSendActiveVersion(agentId: string): Promise { const conn = await CoreExtensionService.getDefaultConnection(); const project = SfProject.getInstance(); diff --git a/src/views/agentCombined/handlers/webviewMessageSender.ts b/src/views/agentCombined/handlers/webviewMessageSender.ts index 9f1599a6..1fadceb8 100644 --- a/src/views/agentCombined/handlers/webviewMessageSender.ts +++ b/src/views/agentCombined/handlers/webviewMessageSender.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import type { AgentViewState } from '../state/agentViewState'; import type { TraceHistoryEntry } from '../../../utils/traceHistory'; import type { JsonTokenColors } from '../../../utils/themeColors'; +import type { SessionListEntry } from '../session'; /** * Handles all outgoing messages to the webview @@ -27,12 +28,18 @@ export class WebviewMessageSender { this.postMessage('sessionStarting', { message: message || 'Starting session...' }); } - sendSessionStarted(welcomeMessage?: string): void { - this.postMessage('sessionStarted', welcomeMessage); + sendSessionStarted(welcomeMessage?: string, sessionId?: string, skipWelcome?: boolean): void { + if (sessionId || skipWelcome) { + this.postMessage('sessionStarted', { welcomeMessage, sessionId, skipWelcome }); + } else { + this.postMessage('sessionStarted', welcomeMessage); + } } - sendSessionEnded(): void { - this.postMessage('sessionEnded', {}); + sendSessionEnded( + previewSessionInfo?: { sessionId: string; sessionType?: 'simulated' | 'live' | 'published' } + ): void { + this.postMessage('sessionEnded', { previewSessionInfo }); } // Compilation messages @@ -81,9 +88,10 @@ export class WebviewMessageSender { sendSetConversation( messages: Array<{ id: string; type: string; content: string; timestamp: number }>, - showPlaceholder: boolean + showPlaceholder: boolean, + previewSessionInfo?: { sessionId: string; sessionType?: 'simulated' | 'live' | 'published' } | null ): void { - this.postMessage('setConversation', { messages, showPlaceholder }); + this.postMessage('setConversation', { messages, showPlaceholder, previewSessionInfo }); } sendTraceHistory(agentId: string, entries: TraceHistoryEntry[]): void { @@ -98,6 +106,10 @@ export class WebviewMessageSender { this.postMessage('noHistoryFound', { agentId }); } + sendSessionList(agentId: string, sessions: SessionListEntry[]): void { + this.postMessage('sessionList', { agentId, sessions }); + } + // Error messages async sendError(message: string, details?: string): Promise { const sanitizedMessage = this.stripHtmlTags(message); diff --git a/src/views/agentCombined/history/historyManager.ts b/src/views/agentCombined/history/historyManager.ts index f2a34151..fcf6ebd2 100644 --- a/src/views/agentCombined/history/historyManager.ts +++ b/src/views/agentCombined/history/historyManager.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import type { AgentViewState } from '../state/agentViewState'; import type { WebviewMessageSender } from '../handlers/webviewMessageSender'; import { getAgentStorageKey } from '../agent/agentUtils'; +import { listSessionsForAgent, type SessionListEntry } from '../session/sessionHistoryService'; /** * Manages conversation and trace history @@ -153,10 +154,13 @@ export class HistoryManager { */ async showHistoryOrPlaceholder(agentId: string, agentSource: AgentSource): Promise { try { - // Load both histories in parallel - const [traceEntries, transcriptEntries] = await Promise.all([ + // Load histories + the cached-session list in parallel. + // The session list is used to identify the most-recent sessionId/type so + // the webview can display Resume vs Start on the start button. + const [traceEntries, transcriptEntries, sessions] = await Promise.all([ this.loadTraceHistoryData(agentId, agentSource), - this.loadConversationHistoryData(agentId, agentSource) + this.loadConversationHistoryData(agentId, agentSource), + listSessionsForAgent(agentId, agentSource).catch(() => [] as SessionListEntry[]) ]); // Send trace history @@ -166,7 +170,13 @@ export class HistoryManager { const historyMessages = this.convertTranscriptToMessages(transcriptEntries); const hasHistory = historyMessages.length > 0; await this.state.setConversationDataAvailable(hasHistory); - this.messageSender.sendSetConversation(historyMessages, !hasHistory); + + // If a conversation is loaded, mark it as resumable. Newest session is first. + const previewSessionInfo = hasHistory && sessions.length > 0 + ? { sessionId: sessions[0].sessionId, sessionType: sessions[0].sessionType } + : null; + this.state.previewedSessionId = previewSessionInfo?.sessionId; + this.messageSender.sendSetConversation(historyMessages, !hasHistory, previewSessionInfo); // Send current or latest trace data if (traceEntries.length > 0) { @@ -179,7 +189,107 @@ export class HistoryManager { } catch (err) { console.error('Error loading history:', err); await this.state.setConversationDataAvailable(false); - this.messageSender.sendSetConversation([], true); + this.messageSender.sendSetConversation([], true, null); + } + } + + /** + * Loads only the trace history for a specific sessionId (from disk) and pushes + * it to the webview. Used when the tracer tab is opened while previewing a + * session — we don't want to re-render the chat, only ensure the tracer has + * the right traces. + */ + async loadAndSendTracesForSession( + agentId: string, + agentSource: AgentSource, + sessionId: string + ): Promise { + const agentStorageKey = getAgentStorageKey(agentId, agentSource); + let traces: any[] = []; + try { + const history = await getAllHistory(agentStorageKey, sessionId); + traces = history.traces || []; + } catch (err) { + console.error('Could not load traces for session:', err); + } + + const sortedTraces = [...traces].sort((a: any, b: any) => { + const timeA = this.getTraceStartTime(a) ?? Infinity; + const timeB = this.getTraceStartTime(b) ?? Infinity; + return timeA - timeB; + }); + const traceEntries: TraceHistoryEntry[] = sortedTraces.map((trace: any, index) => { + const planId = trace.planId || `plan-${index}`; + const startTime = this.getTraceStartTime(trace); + const timestamp = startTime ? new Date(startTime).toISOString() : new Date().toISOString(); + return { + storageKey: agentStorageKey, + agentId, + sessionId: trace.sessionId || sessionId, + planId, + userMessage: this.extractUserMessageFromTrace(trace), + timestamp, + trace + }; + }); + + this.messageSender.sendTraceHistory(agentId, traceEntries); + if (traceEntries.length > 0) { + this.messageSender.sendTraceData(traceEntries[traceEntries.length - 1].trace); + } else { + this.messageSender.sendTraceData({ plan: [], planId: '', sessionId: '' }); + } + } + + /** + * Loads transcript and traces for a specific sessionId (from disk) and pushes + * them to the webview, without starting a session. Used by the History tab + * to preview a prior session before the user clicks Start. + */ + async loadAndSendSessionPreview( + agentId: string, + agentSource: AgentSource, + sessionId: string, + sessionType?: 'simulated' | 'live' | 'published' + ): Promise { + const agentStorageKey = getAgentStorageKey(agentId, agentSource); + let transcript: any[] = []; + let traces: any[] = []; + try { + const history = await getAllHistory(agentStorageKey, sessionId); + transcript = history.transcript || []; + traces = history.traces || []; + } catch (err) { + console.error('Could not load session preview:', err); + } + + const sortedTraces = [...traces].sort((a: any, b: any) => { + const timeA = this.getTraceStartTime(a) ?? Infinity; + const timeB = this.getTraceStartTime(b) ?? Infinity; + return timeA - timeB; + }); + const traceEntries: TraceHistoryEntry[] = sortedTraces.map((trace: any, index) => { + const planId = trace.planId || `plan-${index}`; + const startTime = this.getTraceStartTime(trace); + const timestamp = startTime ? new Date(startTime).toISOString() : new Date().toISOString(); + return { + storageKey: agentStorageKey, + agentId, + sessionId: trace.sessionId || sessionId, + planId, + userMessage: this.extractUserMessageFromTrace(trace), + timestamp, + trace + }; + }); + + const messages = this.convertTranscriptToMessages(transcript); + const hasMessages = messages.length > 0; + await this.state.setConversationDataAvailable(hasMessages); + this.messageSender.sendSetConversation(messages, !hasMessages, { sessionId, sessionType }); + this.messageSender.sendTraceHistory(agentId, traceEntries); + if (traceEntries.length > 0) { + this.messageSender.sendTraceData(traceEntries[traceEntries.length - 1].trace); } } diff --git a/src/views/agentCombined/session/index.ts b/src/views/agentCombined/session/index.ts index 56618ab7..fad0791a 100644 --- a/src/views/agentCombined/session/index.ts +++ b/src/views/agentCombined/session/index.ts @@ -1,2 +1,4 @@ export { SessionManager } from './sessionManager'; export { createSessionStartGuards } from './sessionStartGuards'; +export { listSessionsForAgent } from './sessionHistoryService'; +export type { SessionListEntry } from './sessionHistoryService'; diff --git a/src/views/agentCombined/session/sessionHistoryService.ts b/src/views/agentCombined/session/sessionHistoryService.ts new file mode 100644 index 00000000..323a7341 --- /dev/null +++ b/src/views/agentCombined/session/sessionHistoryService.ts @@ -0,0 +1,133 @@ +import * as path from 'path'; +import { promises as fs } from 'fs'; +import { AgentSource } from '@salesforce/agents'; +import { getAllHistory } from '@salesforce/agents/lib/utils'; +import { SfProject } from '@salesforce/core'; +import { getAgentStorageKey } from '../agent/agentUtils'; + +export type SessionListEntry = { + sessionId: string; + timestamp?: string; + sessionType?: 'simulated' | 'live' | 'published'; + firstUserMessage?: string; +}; + +const resolveProjectLocalSfdx = async (): Promise => { + try { + const project = await SfProject.resolve(); + return path.join(project.getPath(), '.sfdx'); + } catch { + return path.join(process.cwd(), '.sfdx'); + } +}; + +/** + * Reads per-session metadata from `.sfdx/agents//sessions//`, + * which is the shared on-disk format used by the sf CLI plugin. + */ +const readSessionMeta = async ( + sessionDir: string, + storageKey: string +): Promise<{ timestamp?: string; sessionType?: SessionListEntry['sessionType'] }> => { + const result: { timestamp?: string; sessionType?: SessionListEntry['sessionType'] } = {}; + + try { + const raw = await fs.readFile(path.join(sessionDir, 'session-meta.json'), 'utf8'); + const meta = JSON.parse(raw); + if (typeof meta.timestamp === 'string') { + result.timestamp = meta.timestamp; + } + if (meta.sessionType === 'simulated' || meta.sessionType === 'live' || meta.sessionType === 'published') { + result.sessionType = meta.sessionType; + } + } catch { + // No cache marker; fall through to metadata.json + } + + let metadataMockMode: string | undefined; + if (!result.timestamp || !result.sessionType) { + try { + const raw = await fs.readFile(path.join(sessionDir, 'metadata.json'), 'utf8'); + const meta = JSON.parse(raw); + if (!result.timestamp && typeof meta.startTime === 'string') { + result.timestamp = meta.startTime; + } + if (typeof meta.mockMode === 'string') { + metadataMockMode = meta.mockMode; + } + } catch { + // No metadata.json; fall through to mtime + } + } + + if (!result.sessionType) { + if (metadataMockMode === 'Live Test') { + result.sessionType = 'live'; + } else if (metadataMockMode === 'Mock') { + result.sessionType = 'simulated'; + } else if (storageKey.startsWith('0X') && (storageKey.length === 15 || storageKey.length === 18)) { + result.sessionType = 'published'; + } + } + + if (!result.timestamp) { + try { + const stats = await fs.stat(sessionDir); + result.timestamp = stats.mtime.toISOString(); + } catch { + // ignore + } + } + + return result; +}; + +/** + * Lists prior sessions for a single agent, newest first. + * Reads directly from `.sfdx/agents//sessions/` so the list + * matches what the sf CLI plugin sees on disk. + */ +export async function listSessionsForAgent( + agentId: string, + agentSource: AgentSource +): Promise { + const storageKey = getAgentStorageKey(agentId, agentSource); + const base = await resolveProjectLocalSfdx(); + const sessionsDir = path.join(base, 'agents', storageKey, 'sessions'); + + let dirents; + try { + dirents = await fs.readdir(sessionsDir, { withFileTypes: true }); + } catch { + return []; + } + + const enriched = await Promise.all( + dirents + .filter(d => d.isDirectory()) + .map(async dirent => { + const sessionId = dirent.name; + const sessionDir = path.join(sessionsDir, sessionId); + const { timestamp, sessionType } = await readSessionMeta(sessionDir, storageKey); + + let firstUserMessage: string | undefined; + try { + const history = await getAllHistory(storageKey, sessionId); + const firstUser = history.transcript.find(t => t.role === 'user' && t.text); + firstUserMessage = firstUser?.text; + } catch { + // Best-effort; leave undefined + } + + return { sessionId, timestamp, sessionType, firstUserMessage }; + }) + ); + + enriched.sort((a, b) => { + const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return tb - ta; + }); + + return enriched; +} diff --git a/src/views/agentCombined/session/sessionManager.ts b/src/views/agentCombined/session/sessionManager.ts index 9d2d1a8c..ae0c8ac0 100644 --- a/src/views/agentCombined/session/sessionManager.ts +++ b/src/views/agentCombined/session/sessionManager.ts @@ -93,7 +93,7 @@ export class SessionManager { // Send session started message const agentMessage = session.messages.find((msg: any) => msg.type === 'Inform'); - this.messageSender.sendSessionStarted(agentMessage?.message); + this.messageSender.sendSessionStarted(agentMessage?.message, this.state.sessionId); this.state.pendingStartAgentId = undefined; this.state.pendingStartAgentSource = undefined; await this.state.setConversationDataAvailable(true); @@ -123,10 +123,116 @@ export class SessionManager { } } + /** + * Resumes a previously cached session by loading its state from disk. + * Does NOT call preview.start() — keeps the same sessionId. + */ + async resumeSession( + agentId: string, + agentSource: AgentSource, + sessionId: string, + isLiveMode?: boolean, + webviewView?: any + ): Promise { + if (!webviewView) { + throw new Error('Webview is not ready. Please ensure the view is visible.'); + } + + const sessionStartId = this.state.beginSessionStart(); + const { ensureActive, isActive } = createSessionStartGuards(this.state, sessionStartId); + + try { + // Single beat: sessionActive=false + sessionStarting=true, message cleared. + // This avoids a flicker through the "no session" toolbar state. + await this.beginRestart(this.state.isLiveMode ? 'Resuming live test...' : 'Resuming session...'); + ensureActive(); + + // Tear down any prior SDK session in place. The agent instance is + // discarded (replaced below) so the previous session can be safely ended. + if (this.state.agentInstance && this.state.sessionId) { + try { + if (this.state.currentAgentSource === AgentSource.SCRIPT) { + await this.state.agentInstance.preview.end(); + } else { + await this.state.agentInstance.preview.end('UserRequest'); + } + } catch (error) { + console.warn('Error ending previous session before resume:', error); + } + try { + await this.state.agentInstance.restoreConnection(); + } catch (error) { + console.warn('Error restoring connection before resume:', error); + } + this.state.clearSessionState(); + ensureActive(); + } + + const conn = await CoreExtensionService.getDefaultConnection(); + ensureActive(); + + this.state.currentAgentId = agentId; + const project = SfProject.getInstance(); + + this.state.pendingStartAgentId = agentId; + this.state.pendingStartAgentSource = agentSource; + + if (agentSource === AgentSource.SCRIPT) { + await this.initializeScriptAgent(agentId, conn, project, isLiveMode, isActive, ensureActive); + } else { + await this.initializePublishedAgent(agentId, conn, project, ensureActive); + } + + if (!this.state.agentInstance) { + throw new Error('Failed to initialize agent instance.'); + } + + await this.state.agentInstance.resumeSession(sessionId); + ensureActive(); + this.state.sessionId = sessionId; + this.state.sessionAgentId = agentId; + this.logger.debug(`Resumed session for agent ${this.state.currentAgentName}. SessionId: ${sessionId}`); + + // Load conversation history first. The webview's conversationHistory + // handler temporarily flips sessionActive=false; sessionStarted (sent + // below) flips it back true so the input becomes editable. + await this.historyManager.loadAndSendConversationHistory(agentId, agentSource); + + await this.state.setSessionActive(true); + ensureActive(); + await this.state.setSessionStarting(false); + ensureActive(); + + // Send sessionStarted before traces so the tracer's reset-on-start + // doesn't wipe the trace history we're about to send. + this.messageSender.sendSessionStarted(undefined, this.state.sessionId, true); + this.state.pendingStartAgentId = undefined; + this.state.pendingStartAgentSource = undefined; + + await this.historyManager.loadAndSendTraceHistory(agentId, agentSource); + + await this.state.setConversationDataAvailable(true); + } catch (err) { + if (err instanceof SessionStartCancelledError || !isActive()) { + return; + } + + const sfError = SfError.wrap(err); + this.logger.error('Error resuming session', sfError); + await this.state.setSessionStarting(false); + await this.messageSender.sendError(`Failed to resume session: ${sfError.message}`); + await this.state.setResetAgentViewAvailable(true); + await this.state.setSessionErrorState(true); + } + } + /** * Ends the current agent session */ - async endSession(restoreViewCallback?: () => Promise): Promise { + async endSession( + restoreViewCallback?: () => Promise, + options?: { restarting?: boolean } + ): Promise { this.state.cancelPendingSessionStart(); const sessionWasStarting = this.state.isSessionStarting; @@ -134,9 +240,27 @@ export class SessionManager { // This matches the webview's optimistic update for button state await this.state.setSessionActive(false); await this.state.setSessionStarting(false); + // Block toolbar actions while the SDK teardown is in flight. Without this, + // the gap between sessionActive=false and clearSessionState exposes + // toolbar items gated only on !sessionActive && !sessionStarting. + await this.state.setSessionStopping(true); const agentName = this.state.currentAgentName; const sessionId = this.state.sessionId; + // Capture identity before clearSessionState wipes it. After Stop, the + // just-ended session becomes a previewable history-like session: Resume + // and Clear appear on the toolbar, but the chat messages stay on screen. + // We only mark it previewable, we don't reload the conversation. + const endedAgentSource = this.state.currentAgentSource; + const endedSessionType: 'simulated' | 'live' | 'published' | undefined = endedAgentSource + ? endedAgentSource === AgentSource.SCRIPT + ? this.state.isLiveMode + ? 'live' + : 'simulated' + : 'published' + : undefined; + const hadRunningSession = !!(this.state.agentInstance && this.state.sessionId); + if (this.state.agentInstance && this.state.sessionId) { // Restore connection before clearing agent references try { @@ -146,16 +270,29 @@ export class SessionManager { } this.state.clearSessionState(); - this.messageSender.sendSessionEnded(); - } else if (sessionWasStarting) { - this.messageSender.sendSessionEnded(); } this.logger.debug(`Simulation ended. AgentName: ${agentName}, SessionId: ${sessionId}`); + if (hadRunningSession && sessionId && endedSessionType && !options?.restarting) { + // Mark the just-ended session as previewable so the hasLoadedSession + // context flips on (showing the Clear toolbar action) and the webview + // toolbar shows Resume. The chat is left untouched; the conversation + // already on screen is the previewed session's transcript. + // Skipped when this end is part of a restart (mode switch / agent + // change): the upcoming startSession would route through resume, + // which is not what the user asked for. + this.state.previewedSessionId = sessionId; + this.messageSender.sendSessionEnded({ sessionId, sessionType: endedSessionType }); + } else if (hadRunningSession || sessionWasStarting) { + this.messageSender.sendSessionEnded(); + } + if (sessionWasStarting && restoreViewCallback) { await restoreViewCallback(); } + + await this.state.setSessionStopping(false); } /** @@ -303,7 +440,7 @@ export class SessionManager { ensureActive?.(); const agentMessage = session.messages.find((msg: any) => msg.type === 'Inform'); - this.messageSender.sendSessionStarted(agentMessage?.message); + this.messageSender.sendSessionStarted(agentMessage?.message, this.state.sessionId); await this.state.setConversationDataAvailable(true); this.logger.debug(logMessage); diff --git a/src/views/agentCombined/state/agentViewState.ts b/src/views/agentCombined/state/agentViewState.ts index 2b479f22..8bc0151f 100644 --- a/src/views/agentCombined/state/agentViewState.ts +++ b/src/views/agentCombined/state/agentViewState.ts @@ -22,6 +22,9 @@ export class AgentViewState { private _pendingStartAgentId?: string; private _pendingStartAgentSource?: AgentSource; private _pendingSelectAgentId?: string; + // The sessionId currently shown via History tab preview (read-only, not yet started). + // When the user clicks Start, the start path resumes this session instead of creating a new one. + private _previewedSessionId?: string; // Version state private _currentAgentActiveVersion?: number; @@ -173,6 +176,15 @@ export class AgentViewState { this._pendingSelectAgentId = value; } + get previewedSessionId(): string | undefined { + return this._previewedSessionId; + } + + set previewedSessionId(value: string | undefined) { + this._previewedSessionId = value; + void vscode.commands.executeCommand('setContext', 'agentforceDX:hasLoadedSession', !!value); + } + // State update methods async setSessionActive(active: boolean): Promise { this._isSessionActive = active; @@ -184,6 +196,10 @@ export class AgentViewState { await vscode.commands.executeCommand('setContext', 'agentforceDX:sessionStarting', starting); } + async setSessionStopping(stopping: boolean): Promise { + await vscode.commands.executeCommand('setContext', 'agentforceDX:sessionStopping', stopping); + } + beginSessionStart(): number { return ++this._sessionStartOperationId; } @@ -259,5 +275,9 @@ export class AgentViewState { this._pendingStartAgentId = undefined; this._pendingStartAgentSource = undefined; this._pendingSelectAgentId = undefined; + // Route through the setter so the hasLoadedSession context flag is reset + // alongside the field. Direct assignment would leave the toolbar's Clear + // Chat Session action visible after a teardown. + this.previewedSessionId = undefined; } } diff --git a/src/views/agentCombinedViewProvider.ts b/src/views/agentCombinedViewProvider.ts index 772d7a78..fb3155dc 100644 --- a/src/views/agentCombinedViewProvider.ts +++ b/src/views/agentCombinedViewProvider.ts @@ -295,6 +295,21 @@ export class AgentCombinedViewProvider implements vscode.WebviewViewProvider { /** * Resets the current agent view */ + /** + * Drops the currently displayed (resumable) conversation/traces so the user can + * start a fresh session from an empty chat. Does not touch on-disk session data. + */ + public clearLoadedSession(): void { + this.state.previewedSessionId = undefined; + this.state.currentPlanId = undefined; + void this.state.setConversationDataAvailable(false); + this.messageSender.sendSetConversation([], true, null); + if (this.state.currentAgentId) { + this.messageSender.sendTraceHistory(this.state.currentAgentId, []); + } + this.messageSender.sendTraceData({ plan: [], planId: '', sessionId: '' }); + } + public async resetCurrentAgentView(): Promise { if (!this.webviewView) { throw new Error('Agent view is not ready to reset.'); diff --git a/test/packageJson.test.ts b/test/packageJson.test.ts index 66cb96d4..dd4be59f 100644 --- a/test/packageJson.test.ts +++ b/test/packageJson.test.ts @@ -73,19 +73,15 @@ describe('package.json', () => { expect(activateNoSelection?.when).toContain('!agentforceDX:agentSelected'); expect(activateNoSelection?.when).toContain('!agentforceDX:sessionActive'); expect(activateNoSelection?.when).toContain('!agentforceDX:sessionStarting'); - expect(activateNoSelection?.group).toBe('navigation@0'); + expect(activateNoSelection?.group).toBe('navigation@1'); }); - it('should show create agent button before activate agent button', () => { + it('should not show create agent button in the view title bar', () => { const createAgent = viewTitleMenus.find( (menu: any) => menu.command === 'salesforcedx-vscode-agents.createAiAuthoringBundle' ); - const activateAgent = viewTitleMenus.find( - (menu: any) => menu.command === 'salesforcedx-vscode-agents.activateAgent' - ); - expect(createAgent?.group).toBe('navigation@-1'); - expect(activateAgent?.group).toBe('navigation@0'); + expect(createAgent).toBeUndefined(); }); it('should show activate version button only when a published agent is selected', () => { diff --git a/test/services/coreExtensionService.test.ts b/test/services/coreExtensionService.test.ts index e853cfda..726aac9c 100644 --- a/test/services/coreExtensionService.test.ts +++ b/test/services/coreExtensionService.test.ts @@ -33,9 +33,8 @@ jest.mock('semver', () => ({ valid: jest.fn() })); -// Mock @salesforce/core so the fallback path in getDefaultConnection -// doesn't reach into the host's ~/.sfdx config and a real Salesforce -// connection during tests. +// Mock @salesforce/core so getDefaultConnection's fallback path doesn't read +// the developer's real ~/.sfdx config and resolve a live Connection. jest.mock('@salesforce/core', () => ({ ConfigAggregator: { create: jest.fn() @@ -47,6 +46,7 @@ jest.mock('@salesforce/core', () => ({ import { ConfigAggregator, Org } from '@salesforce/core'; + describe('CoreExtensionService', () => { let mockExtension: { packageJSON: { version: string }; exports: CoreExtensionApi }; let mockContext: ExtensionContext; diff --git a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts index c48b09d7..bb40f37f 100644 --- a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts +++ b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts @@ -71,9 +71,15 @@ jest.mock('../../../../src/views/agentCombined/agent/agentUtils', () => ({ getAgentSource: jest.fn() })); +// Mock sessionHistoryService (the session/index reexports it) +jest.mock('../../../../src/views/agentCombined/session', () => ({ + listSessionsForAgent: jest.fn() +})); + // Import after mocks import { WebviewMessageHandlers } from '../../../../src/views/agentCombined/handlers/webviewMessageHandlers'; import { CoreExtensionService } from '../../../../src/services/coreExtensionService'; +import { listSessionsForAgent } from '../../../../src/views/agentCombined/session'; describe('WebviewMessageHandlers', () => { let handlers: WebviewMessageHandlers; @@ -92,32 +98,47 @@ describe('WebviewMessageHandlers', () => { mockState = { agentInstance: undefined, sessionId: '', + sessionAgentId: undefined, currentAgentId: undefined, currentAgentSource: undefined, + currentPlanId: undefined, + previewedSessionId: undefined, isSessionActive: false, isSessionStarting: false, pendingStartAgentId: undefined, pendingStartAgentSource: undefined, setSessionActive: jest.fn().mockResolvedValue(undefined), setSessionStarting: jest.fn().mockResolvedValue(undefined), + setSessionStopping: jest.fn().mockResolvedValue(undefined), setResetAgentViewAvailable: jest.fn().mockResolvedValue(undefined), setSessionErrorState: jest.fn().mockResolvedValue(undefined), + setConversationDataAvailable: jest.fn().mockResolvedValue(undefined), + cancelPendingSessionStart: jest.fn(), clearSessionState: jest.fn() }; mockMessageSender = { sendError: jest.fn().mockResolvedValue(undefined), - sendClearMessages: jest.fn() + sendClearMessages: jest.fn(), + sendSessionList: jest.fn(), + sendSessionStarting: jest.fn(), + sendSessionEnded: jest.fn(), + sendSetConversation: jest.fn(), + sendTraceHistory: jest.fn(), + sendTraceData: jest.fn() }; mockSessionManager = { startSession: jest.fn(), - endSession: jest.fn() + endSession: jest.fn(), + resumeSession: jest.fn().mockResolvedValue(undefined) }; mockHistoryManager = { - loadAndSendTraceHistory: jest.fn(), - showHistoryOrPlaceholder: jest.fn() + loadAndSendTraceHistory: jest.fn().mockResolvedValue(undefined), + showHistoryOrPlaceholder: jest.fn(), + loadAndSendSessionPreview: jest.fn().mockResolvedValue(undefined), + loadAndSendTracesForSession: jest.fn().mockResolvedValue(undefined) }; mockApexDebugManager = {}; @@ -244,4 +265,275 @@ describe('WebviewMessageHandlers', () => { ); }); }); + + describe('listSessions', () => { + it('posts the session list back to the webview', async () => { + const sessions = [ + { sessionId: 'a', timestamp: '2026-05-10T00:00:00Z', sessionType: 'live', firstUserMessage: 'hi' } + ]; + (listSessionsForAgent as jest.Mock).mockResolvedValue(sessions); + + await handlers.handleMessage({ + command: 'listSessions', + data: { agentId: 'agent-1', agentSource: 'script' } + } as any); + + expect(listSessionsForAgent).toHaveBeenCalledWith('agent-1', 'script'); + expect(mockMessageSender.sendSessionList).toHaveBeenCalledWith('agent-1', sessions); + }); + + it('returns an empty list when no agentId is supplied or known', async () => { + await handlers.handleMessage({ command: 'listSessions', data: {} } as any); + + expect(mockMessageSender.sendSessionList).toHaveBeenCalledWith('', []); + expect(listSessionsForAgent).not.toHaveBeenCalled(); + }); + + it('falls back to an empty list when listing throws', async () => { + (listSessionsForAgent as jest.Mock).mockRejectedValue(new Error('boom')); + const originalError = console.error; + console.error = jest.fn(); + + await handlers.handleMessage({ + command: 'listSessions', + data: { agentId: 'agent-1', agentSource: 'script' } + } as any); + + expect(mockMessageSender.sendSessionList).toHaveBeenCalledWith('agent-1', []); + console.error = originalError; + }); + }); + + describe('previewSession', () => { + it('loads the session preview and records previewedSessionId', async () => { + mockState.currentAgentSource = 'script'; + + await handlers.handleMessage({ + command: 'previewSession', + data: { agentId: 'agent-1', sessionId: 'sess-1' } + } as any); + + expect(mockHistoryManager.loadAndSendSessionPreview).toHaveBeenCalledWith( + 'agent-1', + 'script', + 'sess-1', + undefined + ); + expect(mockState.previewedSessionId).toBe('sess-1'); + expect(mockSessionManager.resumeSession).not.toHaveBeenCalled(); + }); + + it('short-circuits when the requested session is already active', async () => { + mockState.isSessionActive = true; + mockState.sessionId = 'sess-1'; + mockState.sessionAgentId = 'agent-1'; + mockState.currentAgentSource = 'script'; + + await handlers.handleMessage({ + command: 'previewSession', + data: { agentId: 'agent-1', sessionId: 'sess-1' } + } as any); + + expect(mockHistoryManager.loadAndSendSessionPreview).not.toHaveBeenCalled(); + }); + + it('throws when sessionId is missing', async () => { + await expect( + handlers.handleMessage({ + command: 'previewSession', + data: { agentId: 'agent-1' } + } as any) + ).rejects.toThrow(/Invalid session ID/); + }); + }); + + describe('endSession message wiring', () => { + it('forwards restarting=true from the message data to sessionManager.endSession', async () => { + // The App.tsx restart queue posts endSession({ restarting: true }) when + // a mode switch or agent change drives a stop-then-start. The handler + // must propagate that flag so sessionManager skips marking the session + // previewable; otherwise the upcoming startSession routes through + // resume. + await handlers.handleMessage({ + command: 'endSession', + data: { restarting: true } + } as any); + + expect(mockSessionManager.endSession).toHaveBeenCalledTimes(1); + const [, options] = mockSessionManager.endSession.mock.calls[0]; + expect(options).toEqual({ restarting: true }); + }); + + it('passes restarting=false when the message has no data (user-initiated Stop)', async () => { + await handlers.handleMessage({ command: 'endSession' } as any); + + expect(mockSessionManager.endSession).toHaveBeenCalledTimes(1); + const [, options] = mockSessionManager.endSession.mock.calls[0]; + expect(options).toEqual({ restarting: false }); + }); + + it('passes restarting=false when the data omits the flag', async () => { + await handlers.handleMessage({ command: 'endSession', data: {} } as any); + + expect(mockSessionManager.endSession).toHaveBeenCalledTimes(1); + const [, options] = mockSessionManager.endSession.mock.calls[0]; + expect(options).toEqual({ restarting: false }); + }); + }); + + describe('startSession with previewed session', () => { + it('routes through resumeSession when a previewed session is set for the same agent', async () => { + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = 'script'; + mockState.previewedSessionId = 'sess-prior'; + + await handlers.handleMessage({ + command: 'startSession', + data: { agentId: 'agent-1', isLiveMode: true } + } as any); + + expect(mockSessionManager.resumeSession).toHaveBeenCalledWith( + 'agent-1', + 'script', + 'sess-prior', + true, + mockWebviewView + ); + expect(mockSessionManager.startSession).not.toHaveBeenCalled(); + expect(mockState.previewedSessionId).toBeUndefined(); + }); + }); + + describe('previewSession with active live session', () => { + it('ends the SDK session and surfaces the stopping spinner before loading the preview', async () => { + const previewEnd = jest.fn().mockResolvedValue(undefined); + const restoreConnection = jest.fn().mockResolvedValue(undefined); + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = 'published'; + mockState.sessionId = 'live-session'; + mockState.sessionAgentId = 'agent-1'; + mockState.isSessionActive = true; + mockState.agentInstance = { preview: { end: previewEnd }, restoreConnection }; + + await handlers.handleMessage({ + command: 'previewSession', + data: { agentId: 'agent-1', sessionId: 'sess-old', sessionType: 'live' } + } as any); + + expect(mockState.cancelPendingSessionStart).toHaveBeenCalled(); + expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith([], true, null); + expect(mockMessageSender.sendTraceHistory).toHaveBeenCalledWith('agent-1', []); + expect(mockMessageSender.sendSessionStarting).toHaveBeenCalledWith('Stopping session...'); + // Published agents end with 'UserRequest' + expect(previewEnd).toHaveBeenCalledWith('UserRequest'); + expect(restoreConnection).toHaveBeenCalled(); + expect(mockState.clearSessionState).toHaveBeenCalled(); + expect(mockHistoryManager.loadAndSendSessionPreview).toHaveBeenCalledWith( + 'agent-1', + 'published', + 'sess-old', + 'live' + ); + expect(mockMessageSender.sendSessionEnded).toHaveBeenCalled(); + expect(mockState.previewedSessionId).toBe('sess-old'); + }); + + it('flips sessionActive=false and emits sessionStarting before invoking the SDK end', async () => { + const callOrder: string[] = []; + const previewEnd = jest.fn().mockImplementation(async () => { + callOrder.push('preview.end'); + }); + mockState.setSessionActive.mockImplementation(async (active: boolean) => { + if (active === false) callOrder.push('setSessionActive(false)'); + }); + mockMessageSender.sendSessionStarting.mockImplementation(() => { + callOrder.push('sendSessionStarting'); + }); + mockMessageSender.sendSetConversation.mockImplementation((messages: any[]) => { + if (messages.length === 0) callOrder.push('sendSetConversation(empty)'); + }); + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = 'published'; + mockState.sessionId = 'live-session'; + mockState.agentInstance = { + preview: { end: previewEnd }, + restoreConnection: jest.fn().mockResolvedValue(undefined) + }; + + await handlers.handleMessage({ + command: 'previewSession', + data: { agentId: 'agent-1', sessionId: 'sess-old', sessionType: 'live' } + } as any); + + // sessionActive must flip to false before the SDK round-trip so toolbar + // actions gated on it (debug, stop) hide immediately. + expect(callOrder.indexOf('setSessionActive(false)')).toBeLessThan( + callOrder.indexOf('preview.end') + ); + // sessionStarting must precede the empty setConversation so the webview's + // isSessionStartingRef is true when the empty payload arrives. + expect(callOrder.indexOf('sendSessionStarting')).toBeLessThan( + callOrder.indexOf('sendSetConversation(empty)') + ); + }); + + it('uses preview.end() with no args for script agents', async () => { + const previewEnd = jest.fn().mockResolvedValue(undefined); + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = 'script'; + mockState.sessionId = 'live-session'; + mockState.agentInstance = { + preview: { end: previewEnd }, + restoreConnection: jest.fn().mockResolvedValue(undefined) + }; + + await handlers.handleMessage({ + command: 'previewSession', + data: { agentId: 'agent-1', sessionId: 'sess-old', sessionType: 'simulated' } + } as any); + + expect(previewEnd).toHaveBeenCalledWith(); + }); + }); + + describe('handleGetTraceData with previewed session', () => { + it('routes to loadAndSendTracesForSession when previewing', async () => { + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = 'script'; + mockState.previewedSessionId = 'sess-old'; + mockState.isSessionActive = false; + + await handlers.handleMessage({ command: 'getTraceData' } as any); + + expect(mockHistoryManager.loadAndSendTracesForSession).toHaveBeenCalledWith( + 'agent-1', + 'script', + 'sess-old' + ); + expect(mockHistoryManager.loadAndSendTraceHistory).not.toHaveBeenCalled(); + }); + + it('falls back to loadAndSendTraceHistory when not previewing', async () => { + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = 'script'; + mockState.previewedSessionId = undefined; + + await handlers.handleMessage({ command: 'getTraceData' } as any); + + expect(mockHistoryManager.loadAndSendTraceHistory).toHaveBeenCalledWith('agent-1', 'script'); + expect(mockHistoryManager.loadAndSendTracesForSession).not.toHaveBeenCalled(); + }); + + it('falls back to loadAndSendTraceHistory when a session is active even if previewedSessionId is set', async () => { + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = 'script'; + mockState.previewedSessionId = 'sess-old'; + mockState.isSessionActive = true; + + await handlers.handleMessage({ command: 'getTraceData' } as any); + + expect(mockHistoryManager.loadAndSendTraceHistory).toHaveBeenCalled(); + expect(mockHistoryManager.loadAndSendTracesForSession).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/views/agentCombined/session/sessionHistoryService.test.ts b/test/views/agentCombined/session/sessionHistoryService.test.ts new file mode 100644 index 00000000..c1eda398 --- /dev/null +++ b/test/views/agentCombined/session/sessionHistoryService.test.ts @@ -0,0 +1,124 @@ +/* + * 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 + */ + +jest.mock('@salesforce/agents', () => ({ + AgentSource: { SCRIPT: 'script', PUBLISHED: 'published' } +})); + +const mockGetAllHistory = jest.fn(); +jest.mock('@salesforce/agents/lib/utils', () => ({ + getAllHistory: (...args: unknown[]) => mockGetAllHistory(...args) +})); + +jest.mock('@salesforce/core', () => ({ + SfProject: { + getInstance: () => ({ getPath: () => '/mock/project' }), + resolve: jest.fn().mockResolvedValue({ getPath: () => '/mock/project' }) + } +})); + +const mockReaddir = jest.fn(); +const mockReadFile = jest.fn(); +const mockStat = jest.fn(); +jest.mock('fs', () => ({ + promises: { + readdir: (...args: unknown[]) => mockReaddir(...args), + readFile: (...args: unknown[]) => mockReadFile(...args), + stat: (...args: unknown[]) => mockStat(...args) + } +})); + +import { AgentSource } from '@salesforce/agents'; +import { listSessionsForAgent } from '../../../../src/views/agentCombined/session/sessionHistoryService'; + +const dirent = (name: string, isDir = true) => ({ name, isDirectory: () => isDir }); + +describe('sessionHistoryService.listSessionsForAgent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockReadFile.mockReset(); + mockReaddir.mockReset(); + mockStat.mockReset(); + mockGetAllHistory.mockResolvedValue({ transcript: [] }); + }); + + it('returns [] when sessions directory is missing', async () => { + mockReaddir.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const result = await listSessionsForAgent('MyAgent', AgentSource.SCRIPT); + + expect(result).toEqual([]); + }); + + it('uses session-meta.json when present', async () => { + mockReaddir.mockResolvedValueOnce([dirent('sess-1')]); + mockReadFile.mockImplementation((p: string) => { + if (p.endsWith('session-meta.json')) { + return Promise.resolve(JSON.stringify({ timestamp: '2026-05-10T10:00:00Z', sessionType: 'live' })); + } + return Promise.reject(new Error('not found')); + }); + mockGetAllHistory.mockResolvedValueOnce({ + transcript: [{ role: 'user', text: 'Hello there' }] + }); + + const [entry] = await listSessionsForAgent('MyAgent', AgentSource.SCRIPT); + + expect(entry).toEqual({ + sessionId: 'sess-1', + timestamp: '2026-05-10T10:00:00Z', + sessionType: 'live', + firstUserMessage: 'Hello there' + }); + }); + + it('falls back to metadata.json mockMode when session-meta is missing', async () => { + mockReaddir.mockResolvedValueOnce([dirent('sess-2')]); + mockReadFile.mockImplementation((p: string) => { + if (p.endsWith('metadata.json')) { + return Promise.resolve(JSON.stringify({ startTime: '2026-05-09T08:00:00Z', mockMode: 'Mock' })); + } + return Promise.reject(new Error('not found')); + }); + + const [entry] = await listSessionsForAgent('MyAgent', AgentSource.SCRIPT); + + expect(entry.timestamp).toBe('2026-05-09T08:00:00Z'); + expect(entry.sessionType).toBe('simulated'); + }); + + it('infers published sessionType from a Bot ID storage key', async () => { + mockReaddir.mockResolvedValueOnce([dirent('sess-3')]); + mockReadFile.mockRejectedValue(new Error('no metadata files')); + mockStat.mockResolvedValueOnce({ mtime: new Date('2026-05-08T08:00:00Z') }); + + const [entry] = await listSessionsForAgent('0XxFakeBot12345', AgentSource.PUBLISHED); + + expect(entry.sessionType).toBe('published'); + expect(entry.timestamp).toBe('2026-05-08T08:00:00.000Z'); + }); + + it('sorts results newest first', async () => { + mockReaddir.mockResolvedValueOnce([dirent('older'), dirent('newer')]); + mockReadFile.mockImplementation((p: string) => { + if (p.includes('older') && p.endsWith('session-meta.json')) { + return Promise.resolve(JSON.stringify({ timestamp: '2026-01-01T00:00:00Z' })); + } + if (p.includes('newer') && p.endsWith('session-meta.json')) { + return Promise.resolve(JSON.stringify({ timestamp: '2026-05-01T00:00:00Z' })); + } + return Promise.reject(new Error('not found')); + }); + + const result = await listSessionsForAgent('MyAgent', AgentSource.SCRIPT); + + expect(result.map(r => r.sessionId)).toEqual(['newer', 'older']); + }); +}); diff --git a/test/views/agentCombined/state/agentViewState.test.ts b/test/views/agentCombined/state/agentViewState.test.ts index 9ef7ce5f..88992e2c 100644 --- a/test/views/agentCombined/state/agentViewState.test.ts +++ b/test/views/agentCombined/state/agentViewState.test.ts @@ -94,4 +94,41 @@ describe('AgentViewState', () => { ); }); }); + + describe('previewedSessionId', () => { + it('flips agentforceDX:hasLoadedSession to true when set to a sessionId', () => { + const executeCommand = vscode.commands.executeCommand as jest.Mock; + executeCommand.mockClear(); + + state.previewedSessionId = 'sess-123'; + + expect(executeCommand).toHaveBeenCalledWith( + 'setContext', + 'agentforceDX:hasLoadedSession', + true + ); + expect(state.previewedSessionId).toBe('sess-123'); + }); + + it('flips agentforceDX:hasLoadedSession to false when cleared', () => { + state.previewedSessionId = 'sess-123'; + const executeCommand = vscode.commands.executeCommand as jest.Mock; + executeCommand.mockClear(); + + state.previewedSessionId = undefined; + + expect(executeCommand).toHaveBeenCalledWith( + 'setContext', + 'agentforceDX:hasLoadedSession', + false + ); + expect(state.previewedSessionId).toBeUndefined(); + }); + + it('clearSessionState clears previewedSessionId', () => { + state.previewedSessionId = 'sess-123'; + state.clearSessionState(); + expect(state.previewedSessionId).toBeUndefined(); + }); + }); }); diff --git a/test/views/historyManager.test.ts b/test/views/historyManager.test.ts index 4316a16b..c4464bbe 100644 --- a/test/views/historyManager.test.ts +++ b/test/views/historyManager.test.ts @@ -100,6 +100,7 @@ describe('HistoryManager', () => { currentAgentId: undefined, currentAgentSource: undefined, currentPlanId: undefined, + previewedSessionId: undefined, setConversationDataAvailable: jest.fn().mockResolvedValue(undefined), setResetAgentViewAvailable: jest.fn().mockResolvedValue(undefined) }; @@ -326,7 +327,8 @@ describe('HistoryManager', () => { expect(mockMessageSender.sendTraceHistory).toHaveBeenCalled(); expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith( [expect.objectContaining({ content: 'Hello' })], - false + false, + expect.any(Object) ); expect(mockState.setConversationDataAvailable).toHaveBeenCalledWith(true); }); @@ -334,7 +336,7 @@ describe('HistoryManager', () => { it('should show placeholder when no history exists', async () => { await historyManager.showHistoryOrPlaceholder('TestAgent', AgentSource.SCRIPT); - expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith([], true); + expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith([], true, null); expect(mockState.setConversationDataAvailable).toHaveBeenCalledWith(false); }); @@ -365,7 +367,29 @@ describe('HistoryManager', () => { await historyManager.showHistoryOrPlaceholder('TestAgent', AgentSource.SCRIPT); expect(mockState.setConversationDataAvailable).toHaveBeenCalledWith(false); - expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith([], true); + expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith([], true, null); + }); + + it('marks the loaded conversation as resumable when sessions exist on disk', async () => { + mockGetAllHistory.mockResolvedValue(mockHistory( + [transcript('Hello', '2025-01-01T00:00:00Z', 'session1', 'user')], + [trace('plan-1', 'session1', 1000)] + )); + // listSessionsForAgent reads from disk; mock its underlying readdir to return one session. + // The simpler approach: spy on the module so it returns a known session list. + const sessionService = require('../../src/views/agentCombined/session/sessionHistoryService'); + jest.spyOn(sessionService, 'listSessionsForAgent').mockResolvedValueOnce([ + { sessionId: 'session1', timestamp: '2025-01-01T00:00:00Z', sessionType: 'simulated' } + ]); + + await historyManager.showHistoryOrPlaceholder('TestAgent', AgentSource.SCRIPT); + + expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith( + [expect.objectContaining({ content: 'Hello' })], + false, + { sessionId: 'session1', sessionType: 'simulated' } + ); + expect(mockState.previewedSessionId).toBe('session1'); }); }); @@ -390,4 +414,84 @@ describe('HistoryManager', () => { ); }); }); + + describe('loadAndSendSessionPreview', () => { + it('reads the requested sessionId from disk and pushes conversation + traces with preview info', async () => { + mockGetAllHistory.mockResolvedValue( + mockHistory( + [transcript('hi', '2026-05-10T00:00:00Z', 'sess-A', 'user')], + [trace('plan-A', 'sess-A', 1000, 'hi')] + ) + ); + + await historyManager.loadAndSendSessionPreview( + 'TestAgent', + AgentSource.SCRIPT, + 'sess-A', + 'simulated' + ); + + // Reads the specific sessionId, not the most recent + expect(mockGetAllHistory).toHaveBeenCalledWith('TestAgent', 'sess-A'); + // Conversation pushed with previewSessionInfo so the start button reads "Resume" + expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith( + [expect.objectContaining({ content: 'hi' })], + false, + { sessionId: 'sess-A', sessionType: 'simulated' } + ); + // Traces pushed for the same session + expect(mockMessageSender.sendTraceHistory).toHaveBeenCalledWith( + 'TestAgent', + [expect.objectContaining({ sessionId: 'sess-A', planId: 'plan-A' })] + ); + expect(mockMessageSender.sendTraceData).toHaveBeenCalled(); + }); + + it('still pushes a placeholder when the session has no transcript or traces', async () => { + mockGetAllHistory.mockResolvedValue(mockHistory([], [])); + + await historyManager.loadAndSendSessionPreview('TestAgent', AgentSource.SCRIPT, 'sess-empty'); + + expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith( + [], + true, + { sessionId: 'sess-empty', sessionType: undefined } + ); + // No traces means no traceData + expect(mockMessageSender.sendTraceData).not.toHaveBeenCalled(); + }); + }); + + describe('loadAndSendTracesForSession', () => { + it('pushes only traces, not setConversation', async () => { + mockGetAllHistory.mockResolvedValue( + mockHistory( + [transcript('hi', '2026-05-10T00:00:00Z', 'sess-A', 'user')], + [trace('plan-A', 'sess-A', 1000)] + ) + ); + + await historyManager.loadAndSendTracesForSession('TestAgent', AgentSource.SCRIPT, 'sess-A'); + + expect(mockGetAllHistory).toHaveBeenCalledWith('TestAgent', 'sess-A'); + expect(mockMessageSender.sendTraceHistory).toHaveBeenCalledWith( + 'TestAgent', + [expect.objectContaining({ sessionId: 'sess-A', planId: 'plan-A' })] + ); + expect(mockMessageSender.sendTraceData).toHaveBeenCalled(); + // Crucially, this method must not touch the conversation panel + expect(mockMessageSender.sendSetConversation).not.toHaveBeenCalled(); + }); + + it('sends an empty traceData payload when the session has no traces', async () => { + mockGetAllHistory.mockResolvedValue(mockHistory([], [])); + + await historyManager.loadAndSendTracesForSession('TestAgent', AgentSource.SCRIPT, 'sess-empty'); + + expect(mockMessageSender.sendTraceHistory).toHaveBeenCalledWith('TestAgent', []); + expect(mockMessageSender.sendTraceData).toHaveBeenCalledWith( + expect.objectContaining({ planId: '', sessionId: '' }) + ); + }); + }); }); diff --git a/test/views/sessionManager.test.ts b/test/views/sessionManager.test.ts index 50fe91fe..73b1d2a1 100644 --- a/test/views/sessionManager.test.ts +++ b/test/views/sessionManager.test.ts @@ -42,15 +42,21 @@ jest.mock('@salesforce/agents', () => ({ createPreviewSessionCache: jest.fn().mockResolvedValue(undefined) })); -// Mock @salesforce/core -jest.mock('@salesforce/core', () => ({ - SfProject: { - getInstance: () => ({ - getPath: () => '/mock/project' - }) - }, - SfError: Error -})); +// Mock @salesforce/core. SfError is aliased to Error so `instanceof SfError` +// passes for plain Errors, matching pre-existing tests, while still exposing +// SfError.wrap which the resumeSession path uses. +jest.mock('@salesforce/core', () => { + const SfErrorMock = Error as any; + SfErrorMock.wrap = (err: unknown) => (err instanceof Error ? err : new Error(String(err))); + return { + SfProject: { + getInstance: () => ({ + getPath: () => '/mock/project' + }) + }, + SfError: SfErrorMock + }; +}); // Mock CoreExtensionService jest.mock('../../src/services/coreExtensionService', () => ({ @@ -96,6 +102,7 @@ describe('SessionManager', () => { sessionStartOperationId: 1, // Match the return value of beginSessionStart setSessionActive: jest.fn().mockResolvedValue(undefined), setSessionStarting: jest.fn().mockResolvedValue(undefined), + setSessionStopping: jest.fn().mockResolvedValue(undefined), setResetAgentViewAvailable: jest.fn().mockResolvedValue(undefined), setSessionErrorState: jest.fn().mockResolvedValue(undefined), setConversationDataAvailable: jest.fn().mockResolvedValue(undefined), @@ -191,6 +198,141 @@ describe('SessionManager', () => { expect(mockMessageSender.sendSessionEnded).toHaveBeenCalled(); }); + + it('marks the just-ended simulated session as previewable on sessionEnded', async () => { + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = AgentSource.SCRIPT; + mockState.sessionAgentId = 'agent-1'; + mockState.agentInstance = { restoreConnection: jest.fn().mockResolvedValue(undefined) }; + mockState.sessionId = 'session-just-finished'; + mockState.isSessionStarting = false; + mockState.isLiveMode = false; + + await sessionManager.endSession(); + + expect(mockState.previewedSessionId).toBe('session-just-finished'); + expect(mockMessageSender.sendSessionEnded).toHaveBeenCalledWith({ + sessionId: 'session-just-finished', + sessionType: 'simulated' + }); + }); + + it('marks live-mode script sessions as live on sessionEnded', async () => { + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = AgentSource.SCRIPT; + mockState.sessionAgentId = 'agent-1'; + mockState.agentInstance = { restoreConnection: jest.fn().mockResolvedValue(undefined) }; + mockState.sessionId = 'session-just-finished'; + mockState.isSessionStarting = false; + mockState.isLiveMode = true; + + await sessionManager.endSession(); + + expect(mockMessageSender.sendSessionEnded).toHaveBeenCalledWith({ + sessionId: 'session-just-finished', + sessionType: 'live' + }); + }); + + it('marks published agent sessions as published on sessionEnded', async () => { + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = AgentSource.PUBLISHED; + mockState.sessionAgentId = 'agent-1'; + mockState.agentInstance = { restoreConnection: jest.fn().mockResolvedValue(undefined) }; + mockState.sessionId = 'session-just-finished'; + mockState.isSessionStarting = false; + + await sessionManager.endSession(); + + expect(mockMessageSender.sendSessionEnded).toHaveBeenCalledWith({ + sessionId: 'session-just-finished', + sessionType: 'published' + }); + }); + + it('does not reload the chat after a normal stop (preview is in-place)', async () => { + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = AgentSource.SCRIPT; + mockState.sessionAgentId = 'agent-1'; + mockState.agentInstance = { restoreConnection: jest.fn().mockResolvedValue(undefined) }; + mockState.sessionId = 'session-just-finished'; + mockState.isSessionStarting = false; + + await sessionManager.endSession(); + + expect(mockHistoryManager.showHistoryOrPlaceholder).not.toHaveBeenCalled(); + }); + + it('sends sessionEnded without preview info when only a starting session is being cancelled', async () => { + mockState.agentInstance = undefined; + mockState.sessionId = undefined; + mockState.isSessionStarting = true; + + await sessionManager.endSession(); + + expect(mockMessageSender.sendSessionEnded).toHaveBeenCalledWith(); + }); + + it('still calls restoreViewCallback when a starting session is cancelled', async () => { + mockState.agentInstance = undefined; + mockState.sessionId = undefined; + mockState.isSessionStarting = true; + + const restoreView = jest.fn().mockResolvedValue(undefined); + await sessionManager.endSession(restoreView); + + expect(restoreView).toHaveBeenCalled(); + }); + + it('records previewedSessionId so the next start routes through resume', async () => { + // This locks in the Resume-after-Stop integration: endSession must + // leave previewedSessionId set on the shared state so the message + // handler's startSession path picks it up and calls resumeSession + // instead of starting a fresh session. + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = AgentSource.SCRIPT; + mockState.sessionAgentId = 'agent-1'; + mockState.agentInstance = { restoreConnection: jest.fn().mockResolvedValue(undefined) }; + mockState.sessionId = 'session-just-finished'; + mockState.isSessionStarting = false; + mockState.isLiveMode = false; + + await sessionManager.endSession(); + + expect(mockState.previewedSessionId).toBe('session-just-finished'); + }); + + it('does not mark the session previewable when called with restarting=true', async () => { + // Mode switch / agent change paths route through endSession({restarting:true}) + // because the next startSession is going to start a fresh session. + // If we marked the session previewable, startSession would route + // through resumeSession instead. + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = AgentSource.SCRIPT; + mockState.sessionAgentId = 'agent-1'; + mockState.agentInstance = { restoreConnection: jest.fn().mockResolvedValue(undefined) }; + mockState.sessionId = 'session-restarting'; + mockState.isSessionStarting = false; + mockState.previewedSessionId = undefined; + + await sessionManager.endSession(undefined, { restarting: true }); + + expect(mockState.previewedSessionId).toBeUndefined(); + // Plain sessionEnded with no preview info. + expect(mockMessageSender.sendSessionEnded).toHaveBeenCalledWith(); + }); + + it('does not record previewedSessionId when only a starting session was cancelled', async () => { + // Edge case: user clicks Stop while still starting; nothing to resume. + mockState.agentInstance = undefined; + mockState.sessionId = undefined; + mockState.isSessionStarting = true; + mockState.previewedSessionId = undefined; + + await sessionManager.endSession(); + + expect(mockState.previewedSessionId).toBeUndefined(); + }); }); describe('restartSession', () => { @@ -406,4 +548,88 @@ describe('SessionManager', () => { }); }); }); + + describe('resumeSession', () => { + const buildMockAgentInstance = (overrides: any = {}) => ({ + name: 'TestAgent', + preview: { + end: jest.fn().mockResolvedValue(undefined) + }, + restoreConnection: jest.fn().mockResolvedValue(undefined), + resumeSession: jest.fn().mockResolvedValue(undefined), + ...overrides + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockState.currentAgentName = 'TestAgent'; + mockState.currentAgentId = 'test-agent-id'; + mockState.currentAgentSource = AgentSource.SCRIPT; + }); + + it('reattaches the agent to the supplied sessionId via SDK resumeSession', async () => { + const instance = buildMockAgentInstance(); + mockAgentInitializer.initializeScriptAgent.mockImplementation(async () => { + mockState.agentInstance = instance; + return instance; + }); + + await sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, {}); + + expect(instance.resumeSession).toHaveBeenCalledWith('sess-prior'); + expect(mockState.sessionId).toBe('sess-prior'); + expect(mockState.sessionAgentId).toBe('test-agent-id'); + }); + + it('sends sessionStarted with skipWelcome=true', async () => { + const instance = buildMockAgentInstance(); + mockAgentInitializer.initializeScriptAgent.mockImplementation(async () => { + mockState.agentInstance = instance; + return instance; + }); + + await sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, {}); + + expect(mockMessageSender.sendSessionStarted).toHaveBeenCalledWith(undefined, 'sess-prior', true); + }); + + it('ends the previous SDK session before reinitializing', async () => { + const previousInstance = buildMockAgentInstance(); + mockState.agentInstance = previousInstance; + mockState.sessionId = 'sess-old'; + const newInstance = buildMockAgentInstance(); + mockAgentInitializer.initializeScriptAgent.mockImplementation(async () => { + mockState.agentInstance = newInstance; + return newInstance; + }); + + await sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, {}); + + expect(previousInstance.preview.end).toHaveBeenCalled(); + expect(mockState.clearSessionState).toHaveBeenCalled(); + expect(newInstance.resumeSession).toHaveBeenCalledWith('sess-prior'); + }); + + it('surfaces resume errors via sendError and sets error state', async () => { + const instance = buildMockAgentInstance({ + resumeSession: jest.fn().mockRejectedValue(new Error('disk read failed')) + }); + mockAgentInitializer.initializeScriptAgent.mockImplementation(async () => { + mockState.agentInstance = instance; + return instance; + }); + + await sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, {}); + + expect(mockMessageSender.sendError).toHaveBeenCalledWith(expect.stringContaining('Failed to resume session')); + expect(mockState.setResetAgentViewAvailable).toHaveBeenCalledWith(true); + expect(mockState.setSessionErrorState).toHaveBeenCalledWith(true); + }); + + it('throws when no webview is provided', async () => { + await expect( + sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, undefined as any) + ).rejects.toThrow(/Webview is not ready/); + }); + }); }); diff --git a/test/webview/AgentPreview.coverage.test.tsx b/test/webview/AgentPreview.coverage.test.tsx index 45bbed12..119bfef1 100644 --- a/test/webview/AgentPreview.coverage.test.tsx +++ b/test/webview/AgentPreview.coverage.test.tsx @@ -553,4 +553,97 @@ describe('AgentPreview - Coverage Tests', () => { expect(vscodeApi.sendChatMessage).toHaveBeenCalledWith('Test message'); }); }); + + describe('Stopping transition', () => { + it('shows the unified "Stopping session..." spinner and hides messages while a stop is pending (simulation)', async () => { + const { rerender, container } = render( + + ); + + // Drop a message into the chat through the setConversation handler. + handlers.get('setConversation')!({ + messages: [{ role: 'agent', content: 'Hello visible!' }] + }); + handlers.get('sessionStarted')!({ sessionId: 'sess-1', skipWelcome: true }); + + await waitFor(() => { + expect(screen.getByText('Hello visible!')).toBeInTheDocument(); + }); + + // Stop is clicked: parent flips isStopPending true. + rerender( + + ); + + // Unified stopping message and spinner. + await waitFor(() => { + expect(screen.getByText('Stopping session...')).toBeInTheDocument(); + }); + expect(container.querySelector('.loading-spinner')).toBeInTheDocument(); + + // Chat transcript is hidden while stopping. + expect(screen.queryByText('Hello visible!')).not.toBeInTheDocument(); + }); + + it('uses the same "Stopping session..." message for live test as for simulation', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Stopping session...')).toBeInTheDocument(); + }); + // No mode-specific phrasing. + expect(screen.queryByText(/Stopping live test/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Stopping simulation/i)).not.toBeInTheDocument(); + }); + + it('shows the unified stopping spinner when the backend drives the history-tab switch path', async () => { + // The history-tab switch path comes from the backend as a sessionStarting + // message with the stopping phrase, surfaced through the standard chat + // loader. Verify the same unified copy reaches the chat area. + const { container } = render( + + ); + + handlers.get('sessionStarting')!({ message: 'Stopping session...' }); + + await waitFor(() => { + expect(screen.getByText('Stopping session...')).toBeInTheDocument(); + }); + expect(container.querySelector('.loading-spinner')).toBeInTheDocument(); + }); + }); }); diff --git a/test/webview/AgentSelector.test.tsx b/test/webview/AgentSelector.test.tsx index 60527c8c..09270246 100644 --- a/test/webview/AgentSelector.test.tsx +++ b/test/webview/AgentSelector.test.tsx @@ -730,6 +730,55 @@ describe('AgentSelector', () => { expect(onStopSession).not.toHaveBeenCalled(); }); + it('keeps the Stop label while a stop is pending after optimistic isSessionActive=false', async () => { + // After Stop is clicked, App optimistically flips isSessionActive=false + // and sets isStopPending=true until sessionEnded arrives. The button + // must keep its Stop label (not flip to Resume/Start) so the user + // doesn't see a label change while the disabled button is mid-stop. + render( + + ); + + messageHandlers.get('availableAgents')!({ agents }); + + await waitFor(() => { + expect(screen.getByText(/Stop Simulation/i)).toBeInTheDocument(); + }); + + expect(screen.queryByText(/Resume Simulation/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Start Simulation/i)).not.toBeInTheDocument(); + }); + + it('keeps the Stop Live Test label on a published agent while a stop is pending', async () => { + const publishedAgents: AgentInfo[] = [{ id: 'pub1', name: 'Pub', type: 'published' }]; + render( + + ); + + messageHandlers.get('availableAgents')!({ agents: publishedAgents }); + + await waitFor(() => { + expect(screen.getByText(/Stop Live Test/i)).toBeInTheDocument(); + }); + + expect(screen.queryByText(/Resume Live Test/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Start Live Test/i)).not.toBeInTheDocument(); + }); + it('disables the published-agent Start button while transitioning', async () => { const publishedAgents: AgentInfo[] = [{ id: 'pub1', name: 'Pub', type: 'published' }]; const onStartSession = jest.fn(); diff --git a/test/webview/AgentTracer.helpers.test.tsx b/test/webview/AgentTracer.helpers.test.tsx index bb1a1170..8248c05c 100644 --- a/test/webview/AgentTracer.helpers.test.tsx +++ b/test/webview/AgentTracer.helpers.test.tsx @@ -675,6 +675,68 @@ describe('AgentTracer helpers', () => { expect(result).toContain('"output"'); }); + it('renders GuardrailsStep with display name and adherence level', () => { + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan', + sessionId: 'session', + plan: [ + { + type: 'GuardrailsStep', + generatedResponse: 'Sure thing!', + instructionAdherence: 'HIGH\n\nThis response adheres to the assigned instructions.' + } + ] + }; + + const items = buildTimelineItems(trace, () => undefined); + expect(items).toHaveLength(1); + expect(items[0].label).toBe('Guardrails Check'); + expect(items[0].description).toBe('HIGH'); + }); + + it('includes generatedResponse and instructionAdherence in GuardrailsStep data', () => { + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan', + sessionId: 'session', + plan: [ + { + type: 'GuardrailsStep', + generatedResponse: 'Hi there.', + instructionAdherence: 'HIGH\n\nReason here.' + } + ] + }; + + const result = getStepData(trace, 0); + expect(result).toContain('"generatedResponse"'); + expect(result).toContain('Hi there.'); + expect(result).toContain('"instructionAdherence"'); + }); + + it('makes GuardrailsStep clickable so the detail panel opens', () => { + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan', + sessionId: 'session', + plan: [ + { + type: 'GuardrailsStep', + generatedResponse: 'Hi there.', + instructionAdherence: 'HIGH' + } + ] + }; + + const indices: number[] = []; + const items = buildTimelineItems(trace, index => indices.push(index)); + expect(items).toHaveLength(1); + expect(items[0].onClick).toBeDefined(); + items[0].onClick?.(); + expect(indices).toEqual([0]); + }); + it('makes FunctionStep clickable when function data exists', () => { const trace = { type: 'PlanSuccessResponse', diff --git a/test/webview/AgentTracer.test.tsx b/test/webview/AgentTracer.test.tsx index 886047c1..8b859199 100644 --- a/test/webview/AgentTracer.test.tsx +++ b/test/webview/AgentTracer.test.tsx @@ -958,4 +958,26 @@ describe('AgentTracer', () => { expect(inputAfter.value).toBe(''); }); }); + + describe('isVisible refetch', () => { + it('requests trace data when becoming visible', () => { + const { rerender } = render(); + const postMessageSpy = jest.spyOn((window as any).vscode, 'postMessage'); + postMessageSpy.mockClear(); + + rerender(); + + expect(postMessageSpy).toHaveBeenCalledWith({ command: 'getTraceData' }); + }); + + it('does not request trace data when becoming hidden', () => { + const { rerender } = render(); + const postMessageSpy = jest.spyOn((window as any).vscode, 'postMessage'); + postMessageSpy.mockClear(); + + rerender(); + + expect(postMessageSpy).not.toHaveBeenCalledWith({ command: 'getTraceData' }); + }); + }); }); diff --git a/test/webview/App.sessionLifecycle.test.tsx b/test/webview/App.sessionLifecycle.test.tsx index 4fe05a89..c0a4cb7d 100644 --- a/test/webview/App.sessionLifecycle.test.tsx +++ b/test/webview/App.sessionLifecycle.test.tsx @@ -83,6 +83,28 @@ jest.mock('../../webview/src/components/shared/TabNavigation', () => { }; }); +const sessionHistoryPropsRef: { current?: any } = {}; + +jest.mock('../../webview/src/components/SessionHistory/SessionHistory', () => { + const React = require('react'); + return function MockSessionHistory(props: any) { + sessionHistoryPropsRef.current = props; + return ( +
+ +
+ ); + }; +}); + import App from '../../webview/src/App'; describe('App session lifecycle', () => { @@ -91,6 +113,7 @@ describe('App session lifecycle', () => { beforeEach(() => { selectorPropsRef.current = undefined; previewPropsRef.current = undefined; + sessionHistoryPropsRef.current = undefined; messageHandlers = new Map(); jest.clearAllMocks(); @@ -198,6 +221,61 @@ describe('App session lifecycle', () => { }); }); + it('flips into preview mode when sessionEnded carries previewSessionInfo', async () => { + // After Stop, the backend marks the just-ended session as previewable. + // The toolbar must show Resume + Clear without any chat reload, so we + // should NOT see a setConversation message wiping the existing + // messages — only sessionEnded with previewSessionInfo. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + await click('stop-button'); + expect(selectorPropsRef.current?.isStopPending).toBe(true); + + trigger('sessionEnded', { + previewSessionInfo: { sessionId: 'sess-just-ended', sessionType: 'simulated' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isPreviewingSession).toBe(true); + expect(selectorPropsRef.current?.isSessionActive).toBe(false); + expect(selectorPropsRef.current?.isStopPending).toBe(false); + }); + }); + + it('does not flip into preview mode when sessionEnded has no previewSessionInfo', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + await click('stop-button'); + trigger('sessionEnded'); + + await waitFor(() => { + expect(selectorPropsRef.current?.isStopPending).toBe(false); + }); + expect(selectorPropsRef.current?.isPreviewingSession).toBe(false); + }); + + it('syncs isLiveMode from previewSessionInfo on sessionEnded', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + // Live session ends → live mode true. + trigger('sessionEnded', { + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'live' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.initialLiveMode).toBe(true); + }); + }); + it('does not trigger AgentPreview loader during Stop (no transition flag on Preview)', async () => { render(); trigger('selectAgent', { agentId: 'agent1' }); @@ -316,4 +394,199 @@ describe('App session lifecycle', () => { }); }); }); + + describe('history row click during active session', () => { + it('keeps isStopPending across the stopping transition until the preview lands', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + // Switch to history tab and simulate clicking a row (the mock fires + // onPreviewStart synchronously to mirror the real component's flow). + await act(async () => { + sessionHistoryPropsRef.current?.onPreviewStart?.(); + }); + + // The toolbar should now be in the optimistic stop state. + expect(selectorPropsRef.current?.isStopPending).toBe(true); + expect(selectorPropsRef.current?.isSessionTransitioning).toBe(true); + + // Backend fires sessionStarting('Stopping session...') as part + // of the preview transition. isStopPending must NOT be cleared here. + trigger('sessionStarting', { message: 'Stopping session...' }); + expect(selectorPropsRef.current?.isStopPending).toBe(true); + + // The preview lands; isStopPending clears so the button can settle on + // its Resume label. + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isStopPending).toBe(false); + expect(selectorPropsRef.current?.isPreviewingSession).toBe(true); + }); + }); + + it('clears isStopPending on a regular sessionStarting (real start path)', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + await act(async () => { + sessionHistoryPropsRef.current?.onPreviewStart?.(); + }); + expect(selectorPropsRef.current?.isStopPending).toBe(true); + + // A regular start (not a stopping transition) should clear stop-pending. + trigger('sessionStarting', { message: 'Starting session...' }); + await waitFor(() => { + expect(selectorPropsRef.current?.isStopPending).toBe(false); + }); + }); + }); + + describe('restart vs stop intent', () => { + it('signals restarting=true on endSession when forceRestart triggers a restart', async () => { + // When the user clicks Start while a session is already active (e.g. + // mode switch on an active session), the App's queue calls endSession + // before startSession. The endSession must include restarting:true so + // the backend skips marking the session previewable; otherwise the + // upcoming startSession would route through resume. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + mockVscodeApi.endSession.mockClear(); + await click('start-button'); + + // Allow the restart queue to flush. + trigger('sessionEnded'); + await waitFor(() => { + expect(mockVscodeApi.endSession).toHaveBeenCalledWith({ restarting: true }); + }); + }); + + it('does not signal restarting on user-initiated Stop', async () => { + // The toolbar Stop button calls endSession directly (no restart). The + // backend should preserve the previewable behavior so Resume appears. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + mockVscodeApi.endSession.mockClear(); + await click('stop-button'); + + // The mocked AgentSelector calls onStopSession only (does not call + // vscodeApi.endSession directly in this test mock); only the App's + // restart queue invokes vscodeApi.endSession. Verify it was NOT called + // here, which proves the Stop path does not enqueue a restart. + expect(mockVscodeApi.endSession).not.toHaveBeenCalled(); + }); + }); + + describe('setConversation handler', () => { + it('flips into preview mode when setConversation includes previewSessionInfo', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isPreviewingSession).toBe(true); + expect(selectorPropsRef.current?.initialLiveMode).toBe(false); + }); + }); + + it('syncs isLiveMode=true for live and published session types', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + + trigger('setConversation', { + messages: [], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'live' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.initialLiveMode).toBe(true); + }); + + trigger('setConversation', { + messages: [], + previewSessionInfo: { sessionId: 'sess-2', sessionType: 'published' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.initialLiveMode).toBe(true); + }); + }); + + it('drops preview flag for a non-resumable load (messages but no preview info)', async () => { + // Simulate a session already in preview mode. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + await waitFor(() => expect(selectorPropsRef.current?.isPreviewingSession).toBe(true)); + + // Legacy auto-load: messages, no previewSessionInfo. + trigger('setConversation', { + messages: [{ role: 'user', content: 'auto-loaded' }] + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isPreviewingSession).toBe(false); + }); + }); + + it('drops preview flag on an explicit toolbar Clear (empty + no preview info, not stopping)', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + await waitFor(() => expect(selectorPropsRef.current?.isPreviewingSession).toBe(true)); + + // Toolbar Clear: empty messages, null preview info, not in a stopping + // transition (isSessionStarting is false here). + trigger('setConversation', { messages: [], previewSessionInfo: null }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isPreviewingSession).toBe(false); + }); + }); + + it('preserves preview flag mid-stop (empty + null + isSessionStarting=true)', async () => { + // Simulate the stopping transition triggered by a history-row click: + // backend sends sessionStarting first, then the empty setConversation, + // then the previewed conversation. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + await waitFor(() => expect(selectorPropsRef.current?.isPreviewingSession).toBe(true)); + + // Backend flips into stopping mode. + trigger('sessionStarting', { message: 'Stopping session...' }); + + // Empty setConversation arrives while sessionStarting is still true. + trigger('setConversation', { messages: [], previewSessionInfo: null }); + + // Preview flag must NOT be cleared during the stopping transition. + expect(selectorPropsRef.current?.isPreviewingSession).toBe(true); + }); + }); }); diff --git a/test/webview/SessionHistory.test.tsx b/test/webview/SessionHistory.test.tsx new file mode 100644 index 00000000..9efcb976 --- /dev/null +++ b/test/webview/SessionHistory.test.tsx @@ -0,0 +1,242 @@ +/* + * 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 + */ + +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +(window as any).AgentSource = { + SCRIPT: 'script', + PUBLISHED: 'published' +}; + +jest.mock('../../webview/src/services/vscodeApi', () => ({ + vscodeApi: { + onMessage: jest.fn(), + listSessions: jest.fn(), + previewSession: jest.fn() + }, + AgentSource: { + SCRIPT: 'script', + PUBLISHED: 'published' + } +})); + +import SessionHistory from '../../webview/src/components/SessionHistory/SessionHistory'; +import { vscodeApi } from '../../webview/src/services/vscodeApi'; + +describe('SessionHistory', () => { + let messageHandlers: Map void>; + + beforeEach(() => { + jest.clearAllMocks(); + messageHandlers = new Map(); + (vscodeApi.onMessage as jest.Mock).mockImplementation((command: string, handler: any) => { + messageHandlers.set(command, handler); + return () => messageHandlers.delete(command); + }); + }); + + const emitSessionList = (agentId: string, sessions: any[]) => { + act(() => { + messageHandlers.get('sessionList')?.({ agentId, sessions }); + }); + }; + + it('renders the placeholder when no agent is selected', () => { + render( + + ); + + expect(screen.getByText(/Select an agent/i)).toBeInTheDocument(); + expect(vscodeApi.listSessions).not.toHaveBeenCalled(); + }); + + it('requests the session list when activated for an agent', () => { + render( + + ); + + expect(vscodeApi.listSessions).toHaveBeenCalledWith('agent-1', 'script'); + }); + + it('renders the empty state with a start button when there are no sessions', () => { + const onGoToPreview = jest.fn(); + render( + + ); + + emitSessionList('agent-1', []); + + expect(screen.getByText(/Use the History tab/i)).toBeInTheDocument(); + expect(screen.getByText(/Start Simulation/i)).toBeInTheDocument(); + }); + + it('renders rows with first message, formatted timestamp and badge', () => { + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + expect(screen.getByText('Hello agent')).toBeInTheDocument(); + expect(screen.getByText('Simulation')).toBeInTheDocument(); + }); + + it('clicking a row previews the session and notifies the parent', async () => { + const onResume = jest.fn(); + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + const user = userEvent.setup(); + await user.click(screen.getByText('Hello agent').closest('button')!); + + expect(vscodeApi.previewSession).toHaveBeenCalledWith( + 'agent-1', + 'sess-1', + expect.objectContaining({ agentSource: 'script' }) + ); + expect(onResume).toHaveBeenCalledWith('sess-1'); + }); + + it('notifies parent of preview start when a row is clicked while a session is active', async () => { + const onPreviewStart = jest.fn(); + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + const user = userEvent.setup(); + await user.click(screen.getByText('Hello agent').closest('button')!); + + expect(onPreviewStart).toHaveBeenCalledTimes(1); + }); + + it('does not flip isLiveMode when a row is clicked (App syncs it on setConversation)', async () => { + // Eagerly flipping isLiveMode would change the toolbar Stop label to the + // new session's mode while the old session is still being stopped, which + // looks wrong. Mode should sync from previewSessionInfo when the preview + // actually lands, not from the row click. + const onLiveModeChange = jest.fn(); + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + const user = userEvent.setup(); + await user.click(screen.getByText('Hello agent').closest('button')!); + + expect(onLiveModeChange).not.toHaveBeenCalled(); + }); + + it('does not notify onPreviewStart when no session is active', async () => { + const onPreviewStart = jest.fn(); + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + const user = userEvent.setup(); + await user.click(screen.getByText('Hello agent').closest('button')!); + + expect(onPreviewStart).not.toHaveBeenCalled(); + }); +}); diff --git a/webview/src/App.tsx b/webview/src/App.tsx index e3ecb590..8c61c546 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import AgentPreview, { AgentPreviewRef } from './components/AgentPreview/AgentPreview.js'; import AgentTracer from './components/AgentTracer/AgentTracer.js'; import AgentSelector from './components/AgentPreview/AgentSelector.js'; +import SessionHistory from './components/SessionHistory/SessionHistory.js'; import TabNavigation from './components/shared/TabNavigation.js'; import { vscodeApi, AgentInfo, AgentSource } from './services/vscodeApi.js'; import './App.css'; @@ -24,7 +25,7 @@ declare global { } const App: React.FC = () => { - const [activeTab, setActiveTab] = useState<'preview' | 'tracer'>('preview'); + const [activeTab, setActiveTab] = useState<'preview' | 'tracer' | 'history'>('preview'); const [displayedAgentId, setDisplayedAgentIdState] = useState(''); const [desiredAgentId, setDesiredAgentId] = useState(''); const [restartTrigger, setRestartTrigger] = useState(0); @@ -33,6 +34,8 @@ const App: React.FC = () => { const [isSessionStarting, setIsSessionStarting] = useState(false); const [isStopPending, setIsStopPending] = useState(false); const [hasSessionError, setHasSessionError] = useState(false); + const [isPreviewingSession, setIsPreviewingSession] = useState(false); + const activeSessionIdRef = useRef(undefined); const [isLiveMode, setIsLiveMode] = useState(false); const [selectedAgentInfo, setSelectedAgentInfo] = useState(null); const [hasAgents, setHasAgents] = useState(false); @@ -143,7 +146,7 @@ const App: React.FC = () => { vscodeApi.getTraceData(); }); - const disposeTestSwitchTab = vscodeApi.onMessage('testSwitchTab', (data: { tab: 'preview' | 'tracer' }) => { + const disposeTestSwitchTab = vscodeApi.onMessage('testSwitchTab', (data: { tab: 'preview' | 'tracer' | 'history' }) => { const tab = data?.tab || 'preview'; console.log('[Webview Test] testSwitchTab received:', tab); setActiveTab(tab); @@ -164,7 +167,7 @@ const App: React.FC = () => { }; }, []); - const handleTabChange = (tab: 'preview' | 'tracer') => { + const handleTabChange = (tab: 'preview' | 'tracer' | 'history') => { setActiveTab(tab); }; @@ -174,6 +177,45 @@ const App: React.FC = () => { vscodeApi.setLiveMode(isLive); }, []); + // Track when the backend pushes a "loaded conversation" so the start button + // can switch between Resume and Start. The setConversation message includes + // previewSessionInfo when a prior session's transcript is being shown. + const isSessionStartingRef = useRef(false); + useEffect(() => { + isSessionStartingRef.current = isSessionStarting; + }, [isSessionStarting]); + + useEffect(() => { + return vscodeApi.onMessage('setConversation', (data: any) => { + const info = data?.previewSessionInfo; + const messages = Array.isArray(data?.messages) ? data.messages : []; + if (info && typeof info.sessionId === 'string') { + setIsPreviewingSession(true); + // Preview has landed; the stopping transition started by a history + // row click is over. Clear the stop-pending flag so the button can + // settle on its Resume label. + setIsStopPending(false); + if (info.sessionType === 'simulated') { + setIsLiveMode(false); + } else if (info.sessionType === 'live' || info.sessionType === 'published') { + setIsLiveMode(true); + } + } else if (messages.length > 0) { + // A conversation with messages but no preview info means a non-resumable + // load (e.g. legacy auto-load). Drop the preview flag. + setIsPreviewingSession(false); + } else if (!isSessionStartingRef.current) { + // Empty conversation, no preview info, not in a stopping transition: + // this is an explicit clear (toolbar action). Drop the preview flag so + // the start button reverts to "Start Simulation/Live Test". + setIsPreviewingSession(false); + } + // Else: empty + null + sessionStarting=true means we're mid-stop. Leave + // isPreviewingSession alone so Resume label stays put. + }); + }, []); + + // Switch to preview tab when a published agent is selected (tracer not supported) // or when no agent is selected useEffect(() => { @@ -184,6 +226,13 @@ const App: React.FC = () => { } }, [selectedAgentInfo, activeTab, desiredAgentId]); + // Switch to preview tab when the agent changes while viewing history + useEffect(() => { + if (activeTab === 'history') { + setActiveTab('preview'); + } + }, [desiredAgentId]); + const handleGoToPreview = useCallback(() => { // If session is not active and we have a desired agent, start the session if (!isSessionActive && !isSessionStarting && desiredAgentId) { @@ -200,6 +249,7 @@ const App: React.FC = () => { const handleAgentChange = useCallback((agentId: string, agentSource?: AgentSource) => { setDesiredAgentId(agentId); + setIsPreviewingSession(false); // Notify the extension about the selected agent // Pass agentSource to avoid expensive re-fetch on the backend // History is now loaded atomically by setSelectedAgentId handler @@ -238,32 +288,63 @@ const App: React.FC = () => { }, []); useEffect(() => { - const disposeSessionStarted = vscodeApi.onMessage('sessionStarted', () => { + const disposeSessionStarted = vscodeApi.onMessage('sessionStarted', (data: any) => { sessionActiveRef.current = true; setIsSessionActive(true); setIsSessionStarting(false); + setIsPreviewingSession(false); + if (data && typeof data === 'object' && typeof data.sessionId === 'string') { + activeSessionIdRef.current = data.sessionId; + } const resolver = sessionStartResolversRef.current.shift(); if (resolver) { resolver(true); } }); - const disposeSessionEnded = vscodeApi.onMessage('sessionEnded', () => { + const disposeSessionEnded = vscodeApi.onMessage('sessionEnded', (data: any) => { sessionActiveRef.current = false; + isSessionStartingRef.current = false; setIsSessionActive(false); setIsSessionStarting(false); + // If the backend marked the just-ended session as previewable, flip + // into preview mode so the toolbar shows Resume + Clear without + // touching the chat messages already on screen. + const info = data?.previewSessionInfo; + if (info && typeof info.sessionId === 'string') { + setIsPreviewingSession(true); + if (info.sessionType === 'simulated') { + setIsLiveMode(false); + } else if (info.sessionType === 'live' || info.sessionType === 'published') { + setIsLiveMode(true); + } + } + // Else: don't clear isPreviewingSession here. The setConversation + // listener owns that flag. When sessionEnded fires after a history-row + // click, a preview is still loaded and Resume should remain available. setIsStopPending(false); + activeSessionIdRef.current = undefined; const resolver = sessionEndResolversRef.current.shift(); if (resolver) { resolver(); } }); - const disposeSessionStarting = vscodeApi.onMessage('sessionStarting', () => { + const disposeSessionStarting = vscodeApi.onMessage('sessionStarting', (data: any) => { sessionActiveRef.current = false; + // Update the ref synchronously so the setConversation listener (which + // may fire on the same message-bus tick) sees the new value before + // React's state-driven effect catches up. + isSessionStartingRef.current = true; setIsSessionActive(false); setIsSessionStarting(true); - setIsStopPending(false); + // Don't clear isStopPending during a stopping-for-preview transition, + // or the toolbar button briefly flips to Start before the preview + // lands and reveals Resume. + const isStoppingTransition = typeof data?.message === 'string' && /stopping/i.test(data.message); + if (!isStoppingTransition) { + setIsStopPending(false); + } // Switch to preview tab when starting a new session setActiveTab('preview'); }); @@ -372,7 +453,10 @@ const App: React.FC = () => { if (hasExistingAgent && (changingAgents || shouldForceRestart) && sessionActiveRef.current) { const waitForEnd = waitForSessionEnd(); - vscodeApi.endSession(); + // Tell the backend this end is part of a restart (mode switch or + // agent change) so it skips marking the session as previewable. + // Otherwise the upcoming startSession would route through resume. + vscodeApi.endSession({ restarting: true }); await waitForEnd; } @@ -426,16 +510,23 @@ const App: React.FC = () => { isSessionActive={isSessionActive} isSessionStarting={isSessionStarting} isSessionTransitioning={isSessionTransitioning || isStopPending} + isStopPending={isStopPending} onLiveModeChange={handleLiveModeChange} initialLiveMode={isLiveMode} onSelectedAgentInfoChange={setSelectedAgentInfo} onStopSession={handleStopSession} onStartSession={handleStartSession} onAgentsAvailabilityChange={handleAgentsAvailabilityChange} + isPreviewingSession={isPreviewingSession} />
- {previewAgentId !== '' && ( - + {previewAgentId !== '' && !isSessionStarting && !isStopPending && ( + )}
@@ -459,6 +550,7 @@ const App: React.FC = () => {
{ onLiveModeChange={handleLiveModeChange} />
+
+ { + setIsPreviewingSession(true); + setActiveTab('preview'); + }} + onPreviewStart={() => { + // History row clicked while a session is active. Mirror the + // optimistic Stop flow so the toolbar button keeps its Stop + // label across the stopping transition until the preview lands. + setIsStopPending(true); + }} + onGoToPreview={handleGoToPreview} + onLiveModeChange={handleLiveModeChange} + /> +
); diff --git a/webview/src/components/AgentPreview/AgentPreview.tsx b/webview/src/components/AgentPreview/AgentPreview.tsx index a9a51201..71a8e278 100644 --- a/webview/src/components/AgentPreview/AgentPreview.tsx +++ b/webview/src/components/AgentPreview/AgentPreview.tsx @@ -8,6 +8,8 @@ import { vscodeApi, Message, AgentInfo } from '../../services/vscodeApi.js'; import { ChatInputRef } from './ChatInput.js'; import './AgentPreview.css'; +export const STOPPING_SESSION_MESSAGE = 'Stopping session...'; + interface AgentPreviewProps { isSessionTransitioning: boolean; onSessionTransitionSettled: () => void; @@ -193,11 +195,21 @@ const AgentPreview = forwardRef( data && Array.isArray(data.messages) ? data.messages.map(normalizeHistoryMessage) : []; setMessages(historyMessages); - setShowPlaceholder(data?.showPlaceholder ?? historyMessages.length === 0); setSessionActive(false); setAgentConnected(false); - setIsLoading(false); setHasSessionError(false); + // If we're mid-stop (sessionStarting=true), keep the loading spinner + // and suppress the empty-state placeholder. The next setConversation + // (with the previewed session's messages) will clear loading. + setIsLoading(prev => { + const explicitlyMidStop = prev && historyMessages.length === 0; + if (!explicitlyMidStop) { + setShowPlaceholder(data?.showPlaceholder ?? historyMessages.length === 0); + } else { + setShowPlaceholder(false); + } + return explicitlyMidStop ? true : false; + }); }); disposers.push(disposeSetConversation); @@ -221,7 +233,7 @@ const AgentPreview = forwardRef( sessionErrorTimestampRef.current = 0; sessionActiveStateRef.current = true; - if (data) { + if (data && !data.skipWelcome) { setMessages(prev => { const newMessages = [...prev]; @@ -247,9 +259,10 @@ const AgentPreview = forwardRef( }); disposers.push(disposeSessionStarted); - const disposeSessionStarting = vscodeApi.onMessage('sessionStarting', () => { + const disposeSessionStarting = vscodeApi.onMessage('sessionStarting', (data: any) => { const currentSelectedAgentId = selectedAgentIdRef.current; const currentPendingAgentId = pendingAgentIdRef.current; + const explicitMessage = typeof data?.message === 'string' ? data.message : undefined; setSessionActive(false); setAgentConnected(false); @@ -264,11 +277,13 @@ const AgentPreview = forwardRef( if (currentPendingAgentId && currentPendingAgentId === currentSelectedAgentId) { setIsLoading(true); - setLoadingMessage('Connecting to agent...'); + setLoadingMessage(explicitMessage ?? 'Connecting to agent...'); } else if (!currentPendingAgentId) { setIsLoading(true); - setLoadingMessage('Loading agent...'); - setMessages([]); + setLoadingMessage(explicitMessage ?? 'Loading agent...'); + if (!explicitMessage) { + setMessages([]); + } } else { setIsLoading(false); } @@ -518,15 +533,27 @@ const AgentPreview = forwardRef( // immediately on click, before the backend confirms sessionEnded. const inputEnabled = agentConnected && parentIsSessionActive && !isStopPending && !isSessionTransitioning; + // While a Stop is in flight, mirror the "Connecting..." loader so the user + // sees the chat-area spinner and a clear stopping message instead of a + // frozen chat that still looks interactive. + const showStopLoader = isStopPending && !isLoading; + const effectiveIsLoading = isLoading || showStopLoader; + const effectiveLoadingMessage = showStopLoader ? STOPPING_SESSION_MESSAGE : loadingMessage; + + // Hide the chat transcript while a Stop is in flight so the user sees only + // the spinner and stopping message. Mirrors the empty-chat treatment during + // session start. + const visibleMessages = showStopLoader ? [] : messages; + return (
- +
diff --git a/webview/src/components/AgentPreview/AgentSelector.css b/webview/src/components/AgentPreview/AgentSelector.css index dd41be0a..3898a71c 100644 --- a/webview/src/components/AgentPreview/AgentSelector.css +++ b/webview/src/components/AgentPreview/AgentSelector.css @@ -9,10 +9,16 @@ } /* Full width start button */ -.agent-selector__start-button { +.agent-selector__actions { + display: flex; width: 100%; } +.agent-selector__start-button { + flex: 1 1 auto; + min-width: 0; +} + .agent-selector__start-button .vscode-button__start, .agent-selector__start-button .vscode-split-button__start { margin-inline-end: 6px; diff --git a/webview/src/components/AgentPreview/AgentSelector.tsx b/webview/src/components/AgentPreview/AgentSelector.tsx index 70271440..d4f8cadf 100644 --- a/webview/src/components/AgentPreview/AgentSelector.tsx +++ b/webview/src/components/AgentPreview/AgentSelector.tsx @@ -10,12 +10,14 @@ interface AgentSelectorProps { isSessionActive?: boolean; isSessionStarting?: boolean; isSessionTransitioning?: boolean; + isStopPending?: boolean; onLiveModeChange?: (isLive: boolean) => void; initialLiveMode?: boolean; onSelectedAgentInfoChange?: (agentInfo: AgentInfo | null) => void; onStopSession?: () => void; onStartSession?: () => void; onAgentsAvailabilityChange?: (hasAgents: boolean, isLoading: boolean) => void; + isPreviewingSession?: boolean; } export interface StartClickParams { @@ -54,17 +56,19 @@ const AgentSelector: React.FC = ({ isSessionActive = false, isSessionStarting = false, isSessionTransitioning = false, + isStopPending = false, onLiveModeChange, initialLiveMode = false, onSelectedAgentInfoChange, onStopSession, onStartSession, - onAgentsAvailabilityChange + onAgentsAvailabilityChange, + isPreviewingSession = false }) => { const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isLiveMode, setIsLiveMode] = useState(initialLiveMode); - const shouldShowStop = isSessionActive; + const shouldShowStop = isSessionActive || isStopPending; const stopIcon = ( = ({ )} - {selectedAgent && - (selectedAgentInfo?.type === AgentSource.PUBLISHED ? ( - - ) : shouldShowStop ? ( - - ) : ( - - {shouldShowStop - ? isLiveMode - ? 'Stop Live Test' - : 'Stop Simulation' - : isLiveMode - ? 'Start Live Test' - : 'Start Simulation'} - - ))} + {selectedAgent && ( +
+ {selectedAgentInfo?.type === AgentSource.PUBLISHED ? ( + + ) : shouldShowStop ? ( + + ) : ( + + {shouldShowStop + ? isLiveMode + ? 'Stop Live Test' + : 'Stop Simulation' + : isPreviewingSession + ? isLiveMode + ? 'Resume Live Test' + : 'Resume Simulation' + : isLiveMode + ? 'Start Live Test' + : 'Start Simulation'} + + )} +
+ )} ); }; diff --git a/webview/src/components/AgentTracer/AgentTracer.tsx b/webview/src/components/AgentTracer/AgentTracer.tsx index c8406ed6..bf9c415b 100644 --- a/webview/src/components/AgentTracer/AgentTracer.tsx +++ b/webview/src/components/AgentTracer/AgentTracer.tsx @@ -217,7 +217,8 @@ const STEP_DISPLAY_NAMES: Record = { OutputEvaluationStep: 'Output Evaluation', PlannerResponseStep: 'Agent Response', UpdateTopicStep: 'Subagent Selected', - FunctionStep: 'Action Executed' + FunctionStep: 'Action Executed', + GuardrailsStep: 'Guardrails Check' }; // Map step types to icons (VS Code icons) @@ -235,7 +236,8 @@ const STEP_ICONS: Record = { OutputEvaluationStep: 'search', PlannerResponseStep: 'agent', UpdateTopicStep: 'tag', - FunctionStep: 'action' + FunctionStep: 'action', + GuardrailsStep: 'shield' }; // Get the description/subtitle for a step based on its type @@ -322,6 +324,18 @@ const getStepDescription = (step: any): string | undefined => { case 'FunctionStep': return step.function?.name || undefined; + case 'GuardrailsStep': { + // instructionAdherence is a multi-line string starting with a level + // like "HIGH" / "MEDIUM" / "LOW", followed by an explanation. Surface + // just the level on the timeline. + const adherence = step.instructionAdherence; + if (typeof adherence === 'string' && adherence.length > 0) { + const firstLine = adherence.split('\n')[0].trim(); + return firstLine || undefined; + } + return undefined; + } + default: return undefined; } @@ -366,7 +380,9 @@ export const buildTimelineItems = ( } const description = getStepDescription(step); - const hasData = step && (step.data || step.message || step.reason || step.function); + const hasData = + step && + (step.data || step.message || step.reason || step.function || step.generatedResponse || step.instructionAdherence); return { status, @@ -424,6 +440,12 @@ export const getStepData = (traceData: PlanSuccessResponse | null, selectedStepI if (step.function) { displayData.function = step.function; } + if (step.generatedResponse) { + displayData.generatedResponse = step.generatedResponse; + } + if (step.instructionAdherence) { + displayData.instructionAdherence = step.instructionAdherence; + } if (Object.keys(displayData).length > 0) { return JSON.stringify(displayData, null, 2); @@ -656,7 +678,7 @@ const AgentTracer: React.FC = ({ setFilterQuery(e.target.value)} aria-label="Filter trace history" diff --git a/webview/src/components/SessionHistory/SessionHistory.css b/webview/src/components/SessionHistory/SessionHistory.css new file mode 100644 index 00000000..53ad847c --- /dev/null +++ b/webview/src/components/SessionHistory/SessionHistory.css @@ -0,0 +1,116 @@ +.session-history-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + height: 100%; +} + + +.session-history-item-button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 18px; + background: none; + border: none; + text-align: left; + color: var(--vscode-foreground); + font-family: inherit; + font-size: 13px; + cursor: pointer; + transition: background-color 0.1s ease; + white-space: nowrap; + overflow: hidden; +} + +.session-history-item-button:hover, +.session-history-item-button:focus { + background-color: var(--vscode-list-hoverBackground); + outline: none; +} + +.session-history-item-message { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--vscode-foreground); + font-weight: 500; +} + +.session-history-item-timestamp { + font-size: 11px; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; + margin-left: auto; + padding-left: 8px; +} + +.session-history-item-type { + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + line-height: 1.2; + flex-shrink: 0; + min-width: 70px; + text-align: center; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +.session-history-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--vscode-descriptionForeground); + height: 100%; + padding: 40px 40px 104px 40px; + max-width: 500px; + margin: 0 auto; +} + +.session-history-placeholder-icon { + margin-bottom: 30px; + opacity: 0.2; + width: 50px; + height: 50px; + background-image: url('../../assets/clock-light.svg'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +body.vscode-dark .session-history-placeholder-icon, +body.vscode-high-contrast .session-history-placeholder-icon { + background-image: url('../../assets/clock-dark.svg'); +} + +.session-history-placeholder p { + margin: 0 0 30px 0; + color: var(--vscode-foreground); + font-size: 14px; + font-weight: 500; + line-height: 1.5; + text-wrap: balance; +} + +.session-history-placeholder .vscode-button, +.session-history-placeholder .vscode-split-button { + width: auto; + min-width: auto; +} + +.session-history-placeholder .vscode-button__start svg, +.session-history-placeholder .vscode-split-button__start svg { + width: 10px; + height: 10px; +} + +.session-history-placeholder .session-history-send-icon .vscode-button__start svg { + width: 16px; + height: 16px; +} diff --git a/webview/src/components/SessionHistory/SessionHistory.tsx b/webview/src/components/SessionHistory/SessionHistory.tsx new file mode 100644 index 00000000..799c7674 --- /dev/null +++ b/webview/src/components/SessionHistory/SessionHistory.tsx @@ -0,0 +1,211 @@ +import React, { useEffect, useState } from 'react'; +import { vscodeApi, AgentInfo, AgentSource, SessionListEntry } from '../../services/vscodeApi.js'; +import { Button } from '../shared/Button.js'; +import { SplitButton } from '../shared/SplitButton.js'; +import './SessionHistory.css'; + +interface SessionHistoryProps { + agentId: string; + agentSource?: AgentSource; + isActive: boolean; + isSessionActive?: boolean; + isLiveMode: boolean; + selectedAgentInfo?: AgentInfo | null; + onResume: (sessionId: string) => void; + onPreviewStart?: () => void; + onGoToPreview?: () => void; + onLiveModeChange?: (isLive: boolean) => void; +} + +const SESSION_TYPE_LABELS: Record, string> = { + simulated: 'Simulation', + live: 'Live Test', + published: 'Live Test' +}; + +const formatTimestamp = (timestamp?: string): string => { + if (!timestamp) { + return ''; + } + try { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return timestamp; + } + const now = new Date(); + const sameYear = date.getFullYear() === now.getFullYear(); + const datePart = date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + ...(sameYear ? {} : { year: '2-digit' }) + }); + const timePart = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + return `${datePart}, ${timePart}`; + } catch { + return timestamp; + } +}; + +const playIcon = ( + + + +); + +const sendIcon = ( + + + +); + +const SessionHistory: React.FC = ({ + agentId, + agentSource, + isActive, + isSessionActive = false, + isLiveMode, + selectedAgentInfo = null, + onResume, + onPreviewStart, + onGoToPreview, + onLiveModeChange +}) => { + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [currentAgentId, setCurrentAgentId] = useState(agentId); + + useEffect(() => { + const dispose = vscodeApi.onMessage('sessionList', (data: { agentId: string; sessions: SessionListEntry[] }) => { + setIsLoading(false); + if (data?.agentId === currentAgentId) { + setSessions(data.sessions || []); + } + }); + return dispose; + }, [currentAgentId]); + + useEffect(() => { + if (!isActive || !agentId) { + return; + } + setCurrentAgentId(agentId); + setIsLoading(true); + setSessions([]); + vscodeApi.listSessions(agentId, agentSource); + }, [isActive, agentId, agentSource]); + + const handleResume = (session: SessionListEntry) => { + if (!agentId) { + return; + } + // Note: don't flip isLiveMode here. App's setConversation handler syncs + // the mode from previewSessionInfo.sessionType when the preview lands, + // so the toolbar button keeps showing the old session's mode through the + // stopping transition and flips to the new mode at the same moment the + // Resume label appears. + // If a session is active, the backend will run a stopping transition + // before the preview lands. Notify the parent so the toolbar's Stop + // label stays put across the transition instead of flipping to Start. + if (isSessionActive) { + onPreviewStart?.(); + } + vscodeApi.previewSession(agentId, session.sessionId, { agentSource, sessionType: session.sessionType }); + onResume(session.sessionId); + }; + + const renderPlaceholder = (message: string, showButton: boolean) => { + const isPublishedAgent = selectedAgentInfo?.type === AgentSource.PUBLISHED; + const buttonText = isSessionActive ? 'Send a message' : isLiveMode ? 'Start Live Test' : 'Start Simulation'; + const handleModeSelect = (value: string) => { + onLiveModeChange?.(value === 'live'); + }; + + return ( +
+
+

{message}

+ {showButton && + onGoToPreview && + (isSessionActive ? ( + + ) : isPublishedAgent ? ( + + ) : ( + + {buttonText} + + ))} +
+ ); + }; + + if (!agentId) { + return renderPlaceholder('Select an agent to see its previous sessions here.', false); + } + + if (isLoading) { + return renderPlaceholder('Loading sessions...', false); + } + + if (sessions.length === 0) { + return renderPlaceholder( + 'Use the History tab to view your past conversations with this agent and resume any of them where you left off without losing context.', + true + ); + } + + return ( +
    + {sessions.map(session => { + const label = session.firstUserMessage?.trim() || '(No messages sent)'; + return ( +
  • + +
  • + ); + })} +
+ ); +}; + +export default SessionHistory; diff --git a/webview/src/components/shared/TabNavigation.css b/webview/src/components/shared/TabNavigation.css index ba0c8810..09a91a6f 100644 --- a/webview/src/components/shared/TabNavigation.css +++ b/webview/src/components/shared/TabNavigation.css @@ -67,6 +67,10 @@ background-image: url('../../assets/tree-light.svg'); } +.tab-icon-clock { + background-image: url('../../assets/clock-light.svg'); +} + body.vscode-dark .tab-icon-comment, body.vscode-high-contrast:not(.vscode-high-contrast-light) .tab-icon-comment { background-image: url('../../assets/comment-dark.svg'); @@ -77,6 +81,11 @@ body.vscode-high-contrast:not(.vscode-high-contrast-light) .tab-icon-tree { background-image: url('../../assets/tree-dark.svg'); } +body.vscode-dark .tab-icon-clock, +body.vscode-high-contrast:not(.vscode-high-contrast-light) .tab-icon-clock { + background-image: url('../../assets/clock-dark.svg'); +} + .tab-navigation-close { display: flex; align-items: center; diff --git a/webview/src/components/shared/TabNavigation.tsx b/webview/src/components/shared/TabNavigation.tsx index d2ef8e53..af82d5e4 100644 --- a/webview/src/components/shared/TabNavigation.tsx +++ b/webview/src/components/shared/TabNavigation.tsx @@ -8,9 +8,10 @@ export interface Tab { } interface TabNavigationProps { - activeTab: number | 'preview' | 'tracer'; + activeTab: number | 'preview' | 'tracer' | 'history'; onTabChange: (tab: any) => void; showTracerTab?: boolean; + showHistoryTab?: boolean; tabs?: Tab[]; onClose?: () => void; } @@ -19,6 +20,7 @@ const TabNavigation: React.FC = ({ activeTab, onTabChange, showTracerTab = false, + showHistoryTab = false, tabs, onClose }) => { @@ -50,7 +52,7 @@ const TabNavigation: React.FC = ({ React.useEffect(() => { updateIndicator(); - }, [updateIndicator, showTracerTab]); + }, [updateIndicator, showTracerTab, showHistoryTab]); // Update indicator on window resize (for responsive behavior) React.useEffect(() => { @@ -114,6 +116,18 @@ const TabNavigation: React.FC = ({ )} + {showHistoryTab && ( + + )} )} {indicatorStyle.width > 0 && ( diff --git a/webview/src/components/shared/Timeline.tsx b/webview/src/components/shared/Timeline.tsx index ab499403..7ab5dc3a 100644 --- a/webview/src/components/shared/Timeline.tsx +++ b/webview/src/components/shared/Timeline.tsx @@ -22,7 +22,8 @@ export type TimelineIconName = | 'check-all' | 'error' | 'agent' - | 'action'; + | 'action' + | 'shield'; export interface TimelineItemProps { /** @@ -245,6 +246,17 @@ const getIconByName = (name: TimelineIconName): React.ReactNode => { /> ); + case 'shield': + return ( + + + + ); default: return null; } diff --git a/webview/src/services/vscodeApi.ts b/webview/src/services/vscodeApi.ts index e7a375cb..bdc76c49 100644 --- a/webview/src/services/vscodeApi.ts +++ b/webview/src/services/vscodeApi.ts @@ -40,6 +40,13 @@ export interface AgentInfo { activeVersion?: number; } +export interface SessionListEntry { + sessionId: string; + timestamp?: string; + sessionType?: 'simulated' | 'live' | 'published'; + firstUserMessage?: string; +} + export interface TraceHistoryMessageEntry { storageKey: string; agentId: string; @@ -140,13 +147,22 @@ class VSCodeApiService { this.vscode?.postMessage({ command, data }); } + // Dispatch a synthetic message to local listeners only (not sent to the extension). + // Useful for optimistic UI updates that should mirror an extension-driven event. + emitLocal(command: string, data?: any) { + const handlers = this.messageHandlers.get(command); + if (handlers) { + handlers.forEach(handler => handler(data)); + } + } + // Agent session management startSession(agentId: string, options?: { isLiveMode?: boolean; agentSource?: string }) { this.postMessage('startSession', { agentId, ...options }); } - endSession() { - this.postMessage('endSession'); + endSession(options?: { restarting?: boolean }) { + this.postMessage('endSession', options); } sendChatMessage(message: string) { @@ -213,6 +229,23 @@ class VSCodeApiService { this.postMessage('openTraceJson', { entry }); } + // Session history + listSessions(agentId: string, agentSource?: AgentSource) { + this.postMessage('listSessions', { agentId, agentSource }); + } + + previewSession( + agentId: string, + sessionId: string, + options?: { agentSource?: AgentSource; sessionType?: 'simulated' | 'live' | 'published' } + ) { + this.postMessage('previewSession', { agentId, sessionId, ...options }); + } + + clearPreviewedSession() { + this.postMessage('clearPreviewedSession'); + } + // Test support - send test response messages postTestMessage(command: string, data?: any) { this.postMessage(command, data);