From 02a0bb6cf151a5b673a6e02283bc2edab3866979 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Wed, 13 May 2026 00:38:14 +0100 Subject: [PATCH 1/3] feat: add History tab to resume prior agent sessions from disk Surfaces a new History tab in the agent webview that lists prior cached sessions read from .sfdx/agents//sessions/, the same on-disk format the sf CLI plugin uses. Clicking a row reattaches the agent via SDK resumeSession(sessionId), restores the transcript and traces, and hands control back to the Preview tab without restarting if the chosen session is already active. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/webviewMessageHandlers.ts | 54 +++++ .../handlers/webviewMessageSender.ts | 13 +- src/views/agentCombined/session/index.ts | 2 + .../session/sessionHistoryService.ts | 133 ++++++++++++ .../agentCombined/session/sessionManager.ts | 107 +++++++++- webview/src/App.tsx | 50 ++++- .../components/AgentPreview/AgentPreview.tsx | 2 +- .../SessionHistory/SessionHistory.css | 114 ++++++++++ .../SessionHistory/SessionHistory.tsx | 198 ++++++++++++++++++ .../src/components/shared/TabNavigation.css | 9 + .../src/components/shared/TabNavigation.tsx | 18 +- webview/src/services/vscodeApi.ts | 25 +++ 12 files changed, 713 insertions(+), 12 deletions(-) create mode 100644 src/views/agentCombined/session/sessionHistoryService.ts create mode 100644 webview/src/components/SessionHistory/SessionHistory.css create mode 100644 webview/src/components/SessionHistory/SessionHistory.tsx diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index a76c27b5..23c1774f 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 @@ -54,6 +55,8 @@ 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), + resumeSession: async msg => await this.handleResumeSession(msg), // Test-specific commands for integration tests clearMessages: async () => { // Clear messages in the webview - no-op on extension side @@ -383,6 +386,57 @@ 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, []); + } + } + + private async handleResumeSession(message: AgentMessage): Promise { + const data = message.data as + | { agentId?: string; agentSource?: AgentSource; sessionId?: string; isLiveMode?: boolean } + | 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.`); + } + + let agentSource = data?.agentSource ?? this.state.currentAgentSource; + if (!agentSource) { + agentSource = await getAgentSource(agentId); + } + this.state.currentAgentSource = agentSource; + + const isLiveMode = data?.isLiveMode ?? this.state.isLiveMode ?? false; + + // If the requested session is already the active one, no need to restart + if ( + this.state.isSessionActive && + this.state.sessionId === sessionId && + this.state.sessionAgentId === agentId + ) { + return; + } + + await this.sessionManager.resumeSession(agentId, agentSource, sessionId, isLiveMode, this.webviewView); + } + 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..2bc2f69a 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,8 +28,12 @@ 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 { @@ -98,6 +103,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/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..51650451 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,6 +123,109 @@ 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 */ @@ -303,7 +406,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/webview/src/App.tsx b/webview/src/App.tsx index e77fc4fc..e32dd8e5 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); @@ -32,6 +33,7 @@ const App: React.FC = () => { const [isSessionActive, setIsSessionActive] = useState(false); const [isSessionStarting, setIsSessionStarting] = useState(false); const [hasSessionError, setHasSessionError] = useState(false); + const activeSessionIdRef = useRef(undefined); const [isLiveMode, setIsLiveMode] = useState(false); const [selectedAgentInfo, setSelectedAgentInfo] = useState(null); const [hasAgents, setHasAgents] = useState(false); @@ -133,7 +135,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); @@ -154,7 +156,7 @@ const App: React.FC = () => { }; }, []); - const handleTabChange = (tab: 'preview' | 'tracer') => { + const handleTabChange = (tab: 'preview' | 'tracer' | 'history') => { setActiveTab(tab); }; @@ -174,6 +176,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) { @@ -209,10 +218,13 @@ const App: React.FC = () => { }, []); useEffect(() => { - const disposeSessionStarted = vscodeApi.onMessage('sessionStarted', () => { + const disposeSessionStarted = vscodeApi.onMessage('sessionStarted', (data: any) => { sessionActiveRef.current = true; setIsSessionActive(true); setIsSessionStarting(false); + if (data && typeof data === 'object' && typeof data.sessionId === 'string') { + activeSessionIdRef.current = data.sessionId; + } const resolver = sessionStartResolversRef.current.shift(); if (resolver) { resolver(true); @@ -223,6 +235,7 @@ const App: React.FC = () => { sessionActiveRef.current = false; setIsSessionActive(false); setIsSessionStarting(false); + activeSessionIdRef.current = undefined; const resolver = sessionEndResolversRef.current.shift(); if (resolver) { resolver(); @@ -379,7 +392,12 @@ const App: React.FC = () => { />
{previewAgentId !== '' && !isSessionStarting && ( - + )}
)} @@ -408,6 +426,28 @@ const App: React.FC = () => { onLiveModeChange={handleLiveModeChange} /> +
+ { + const isAlreadyActive = sessionActiveRef.current && activeSessionIdRef.current === sessionId; + if (!isAlreadyActive) { + sessionActiveRef.current = false; + setIsSessionActive(false); + setIsSessionStarting(true); + vscodeApi.emitLocal('sessionStarting', { message: 'Resuming session...' }); + } + setActiveTab('preview'); + }} + onGoToPreview={handleGoToPreview} + onLiveModeChange={handleLiveModeChange} + /> +
); diff --git a/webview/src/components/AgentPreview/AgentPreview.tsx b/webview/src/components/AgentPreview/AgentPreview.tsx index e3d80ef2..65d62ed6 100644 --- a/webview/src/components/AgentPreview/AgentPreview.tsx +++ b/webview/src/components/AgentPreview/AgentPreview.tsx @@ -205,7 +205,7 @@ const AgentPreview = forwardRef( sessionErrorTimestampRef.current = 0; sessionActiveStateRef.current = true; - if (data) { + if (data && !data.skipWelcome) { setMessages(prev => { const newMessages = [...prev]; diff --git a/webview/src/components/SessionHistory/SessionHistory.css b/webview/src/components/SessionHistory/SessionHistory.css new file mode 100644 index 00000000..d6fb9953 --- /dev/null +++ b/webview/src/components/SessionHistory/SessionHistory.css @@ -0,0 +1,114 @@ +.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; + 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..4c37159d --- /dev/null +++ b/webview/src/components/SessionHistory/SessionHistory.tsx @@ -0,0 +1,198 @@ +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; + 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, + 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 = (sessionId: string) => { + if (!agentId) { + return; + } + vscodeApi.resumeSession(agentId, sessionId, { isLiveMode, agentSource }); + onResume(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( + 'History lists your prior conversations with this agent so you can pick one back up 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/services/vscodeApi.ts b/webview/src/services/vscodeApi.ts index e7a375cb..5f754633 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,6 +147,15 @@ 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 }); @@ -213,6 +229,15 @@ class VSCodeApiService { this.postMessage('openTraceJson', { entry }); } + // Session history + listSessions(agentId: string, agentSource?: AgentSource) { + this.postMessage('listSessions', { agentId, agentSource }); + } + + resumeSession(agentId: string, sessionId: string, options?: { isLiveMode?: boolean; agentSource?: AgentSource }) { + this.postMessage('resumeSession', { agentId, sessionId, ...options }); + } + // Test support - send test response messages postTestMessage(command: string, data?: any) { this.postMessage(command, data); From 6a63fd07c82238469bc527316c9e18ec553fd69c Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Wed, 13 May 2026 00:41:14 +0100 Subject: [PATCH 2/3] chore: regenerate package-lock files Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 26 ++++++++++---------------- webview/package-lock.json | 28 +++++++++------------------- 2 files changed, 19 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0baea115..25c45088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -297,7 +297,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -898,7 +897,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -922,7 +920,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2778,7 +2775,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3001,7 +2999,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3013,7 +3010,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4186,7 +4182,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5108,7 +5103,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -6836,7 +6832,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7929,7 +7924,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8375,6 +8369,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9838,6 +9833,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9853,6 +9849,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10106,7 +10103,6 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10120,7 +10116,6 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10134,7 +10129,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/read": { "version": "1.0.7", @@ -11583,8 +11579,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tunnel": { "version": "0.0.6", @@ -11649,7 +11644,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/webview/package-lock.json b/webview/package-lock.json index f1324810..e90ba002 100644 --- a/webview/package-lock.json +++ b/webview/package-lock.json @@ -104,7 +104,6 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -720,7 +719,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -744,7 +742,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2576,7 +2573,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2745,7 +2743,6 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2757,7 +2754,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2842,7 +2838,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3302,7 +3297,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3592,7 +3586,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3952,7 +3945,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -4083,7 +4077,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5033,7 +5026,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -6029,7 +6021,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -6218,6 +6209,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6788,6 +6780,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6803,6 +6796,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6864,7 +6858,6 @@ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6878,7 +6871,6 @@ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -6892,7 +6884,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -6995,7 +6988,6 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7581,7 +7573,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7708,7 +7699,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", From 7414fa18397d127e7318fc86cec4b06a92ed2522 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Wed, 13 May 2026 00:59:59 +0100 Subject: [PATCH 3/3] test: cover History tab session resume flow Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/webviewMessageHandlers.test.ts | 92 ++++++++++- .../session/sessionHistoryService.test.ts | 124 ++++++++++++++ test/views/sessionManager.test.ts | 108 +++++++++++-- test/webview/SessionHistory.test.tsx | 151 ++++++++++++++++++ 4 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 test/views/agentCombined/session/sessionHistoryService.test.ts create mode 100644 test/webview/SessionHistory.test.tsx diff --git a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts index f0662503..53a11971 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; @@ -107,12 +113,14 @@ describe('WebviewMessageHandlers', () => { mockMessageSender = { sendError: jest.fn().mockResolvedValue(undefined), - sendClearMessages: jest.fn() + sendClearMessages: jest.fn(), + sendSessionList: jest.fn() }; mockSessionManager = { startSession: jest.fn(), - endSession: jest.fn() + endSession: jest.fn(), + resumeSession: jest.fn().mockResolvedValue(undefined) }; mockHistoryManager = { @@ -244,4 +252,84 @@ 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('resumeSession', () => { + it('forwards to sessionManager.resumeSession with resolved agentSource', async () => { + mockState.currentAgentSource = 'script'; + + await handlers.handleMessage({ + command: 'resumeSession', + data: { agentId: 'agent-1', sessionId: 'sess-1', isLiveMode: false } + } as any); + + expect(mockSessionManager.resumeSession).toHaveBeenCalledWith( + 'agent-1', + 'script', + 'sess-1', + false, + mockWebviewView + ); + }); + + it('short-circuits when the requested session is already active for the same agent', async () => { + mockState.isSessionActive = true; + mockState.sessionId = 'sess-1'; + mockState.sessionAgentId = 'agent-1'; + mockState.currentAgentSource = 'script'; + + await handlers.handleMessage({ + command: 'resumeSession', + data: { agentId: 'agent-1', sessionId: 'sess-1' } + } as any); + + expect(mockSessionManager.resumeSession).not.toHaveBeenCalled(); + }); + + it('throws when sessionId is missing', async () => { + await expect( + handlers.handleMessage({ + command: 'resumeSession', + data: { agentId: 'agent-1' } + } as any) + ).rejects.toThrow(/Invalid session ID/); + }); + }); }); 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/sessionManager.test.ts b/test/views/sessionManager.test.ts index 50fe91fe..2425a6c5 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', () => ({ @@ -406,4 +412,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/SessionHistory.test.tsx b/test/webview/SessionHistory.test.tsx new file mode 100644 index 00000000..3684a62f --- /dev/null +++ b/test/webview/SessionHistory.test.tsx @@ -0,0 +1,151 @@ +/* + * 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(), + resumeSession: 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(/History lists your prior conversations/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 resumes 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.resumeSession).toHaveBeenCalledWith( + 'agent-1', + 'sess-1', + expect.objectContaining({ isLiveMode: false, agentSource: 'script' }) + ); + expect(onResume).toHaveBeenCalledWith('sess-1'); + }); +});