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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 10 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions src/views/agentCombined/handlers/webviewMessageHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -383,6 +386,57 @@ export class WebviewMessageHandlers {
this.messageSender.sendLiveMode(this.state.isLiveMode);
}

private async handleListSessions(message: AgentMessage): Promise<void> {
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<void> {
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<void> {
const conn = await CoreExtensionService.getDefaultConnection();
const project = SfProject.getInstance();
Expand Down
13 changes: 11 additions & 2 deletions src/views/agentCombined/handlers/webviewMessageSender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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<void> {
const sanitizedMessage = this.stripHtmlTags(message);
Expand Down
2 changes: 2 additions & 0 deletions src/views/agentCombined/session/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { SessionManager } from './sessionManager';
export { createSessionStartGuards } from './sessionStartGuards';
export { listSessionsForAgent } from './sessionHistoryService';
export type { SessionListEntry } from './sessionHistoryService';
133 changes: 133 additions & 0 deletions src/views/agentCombined/session/sessionHistoryService.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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/<key>/sessions/<sessionId>/`,
* 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/<storageKey>/sessions/` so the list
* matches what the sf CLI plugin sees on disk.
*/
export async function listSessionsForAgent(
agentId: string,
agentSource: AgentSource
): Promise<SessionListEntry[]> {
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;
}
Loading
Loading