From 5fc8f1679b9a3abd97547eeae883196c9ea9e084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Rivas?= Date: Fri, 24 Apr 2026 16:00:59 -0300 Subject: [PATCH 1/7] feat: add timestamp, session type, and index to agent preview sessions output W-22203667 - Add `timestamp` (ISO string) and `sessionType` (simulated/live/published) to `session-meta.json` written by `agent preview start` - Introduce `index.json` in the sessions directory to maintain creation-ordered session metadata for easier browsing by humans and agents - Surface new fields as "Started At" and "Session Type" columns in `agent preview sessions` table and JSON output - `listCachedSessions` prefers the index for ordered results, falls back to directory scan for sessions created before this change - Update JSON schema and add tests for index write, removal, ordering, and fallback Co-Authored-By: Claude Sonnet 4.6 --- messages/agent.preview.sessions.md | 12 ++- schemas/agent-preview-sessions.json | 6 ++ src/commands/agent/preview/sessions.ts | 20 ++++- src/commands/agent/preview/start.ts | 11 ++- src/previewSessionStore.ts | 115 +++++++++++++++++++------ test/previewSessionStore.test.ts | 76 +++++++++++++++- 6 files changed, 207 insertions(+), 33 deletions(-) diff --git a/messages/agent.preview.sessions.md b/messages/agent.preview.sessions.md index 27dc69c8..64110482 100644 --- a/messages/agent.preview.sessions.md +++ b/messages/agent.preview.sessions.md @@ -6,7 +6,7 @@ List all known programmatic agent preview sessions. This command lists the agent preview sessions that were started with the "agent preview start" command and are still in the local cache. Use this command to discover specific session IDs that you can pass to the "agent preview send" or "agent preview end" commands with the --session-id flag. -Programmatic agent preview sessions can be started for both published activated agents and by using an agent's local authoring bundle, which contains its Agent Script file. In this command's output table, the Agent column contains either the API name of the authoring bundle or the published agent, whichever was used when starting the session. In the table, if the same API name has multiple rows with different session IDs, then it means that you previously started multiple preview sessions with the associated agent. +Programmatic agent preview sessions can be started for both published activated agents and by using an agent's local authoring bundle, which contains its Agent Script file. In this command's output table, the Agent column contains either the API name of the authoring bundle or the published agent, whichever was used when starting the session. In the table, if the same API name has multiple rows with different session IDs, then it means that you previously started multiple preview sessions with the associated agent. # output.empty @@ -20,8 +20,16 @@ Agent (authoring bundle or API name) Session ID +# output.tableHeader.timestamp + +Started At + +# output.tableHeader.sessionType + +Session Type + # examples - List all cached agent preview sessions: - <%= config.bin %> <%= command.id %> + <%= config.bin %> <%= command.id %> diff --git a/schemas/agent-preview-sessions.json b/schemas/agent-preview-sessions.json index 37c008c0..87101e87 100644 --- a/schemas/agent-preview-sessions.json +++ b/schemas/agent-preview-sessions.json @@ -15,6 +15,12 @@ }, "sessionId": { "type": "string" + }, + "timestamp": { + "type": "string" + }, + "sessionType": { + "type": "string" } }, "required": ["agentId", "sessionId"], diff --git a/src/commands/agent/preview/sessions.ts b/src/commands/agent/preview/sessions.ts index ab15d221..8c67ea0c 100644 --- a/src/commands/agent/preview/sessions.ts +++ b/src/commands/agent/preview/sessions.ts @@ -21,7 +21,13 @@ import { listCachedSessions } from '../../../previewSessionStore.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.sessions'); -export type AgentPreviewSessionsResult = Array<{ agentId: string; displayName?: string; sessionId: string }>; +export type AgentPreviewSessionsResult = Array<{ + agentId: string; + displayName?: string; + sessionId: string; + timestamp?: string; + sessionType?: string; +}>; export default class AgentPreviewSessions extends SfCommand { public static readonly summary = messages.getMessage('summary'); @@ -36,9 +42,9 @@ export default class AgentPreviewSessions extends SfCommand { const entries = await listCachedSessions(this.project!); const rows: AgentPreviewSessionsResult = []; - for (const { agentId, displayName, sessionIds } of entries) { - for (const sessionId of sessionIds) { - rows.push({ agentId, displayName, sessionId }); + for (const { agentId, displayName, sessions } of entries) { + for (const { sessionId, timestamp, sessionType } of sessions) { + rows.push({ agentId, displayName, sessionId, timestamp, sessionType }); } } @@ -53,15 +59,21 @@ export default class AgentPreviewSessions extends SfCommand ({ agent: r.displayName ?? r.agentId, sessionId: r.sessionId, + timestamp: r.timestamp ?? '', + sessionType: r.sessionType ?? '', })); this.table({ data: tableData, columns: [ { key: 'agent', name: agentColumnHeader }, { key: 'sessionId', name: sessionIdHeader }, + { key: 'timestamp', name: timestampHeader }, + { key: 'sessionType', name: sessionTypeHeader }, ], }); return rows; diff --git a/src/commands/agent/preview/start.ts b/src/commands/agent/preview/start.ts index c360cfe6..38f2bef0 100644 --- a/src/commands/agent/preview/start.ts +++ b/src/commands/agent/preview/start.ts @@ -157,7 +157,8 @@ export default class AgentPreviewStart extends SfCommand; /** * Save a marker so send/end can validate that the session was started for this agent. @@ -31,12 +39,26 @@ export type SessionMeta = { displayName?: string }; */ export async function createCache( agent: ScriptAgent | ProductionAgent, - options?: { displayName?: string } + options?: { displayName?: string; sessionType?: SessionType } ): Promise { const historyDir = await agent.getHistoryDir(); const metaPath = join(historyDir, SESSION_META_FILE); - const meta: SessionMeta = { displayName: options?.displayName }; + const meta: SessionMeta = { + displayName: options?.displayName, + timestamp: new Date().toISOString(), + sessionType: options?.sessionType, + }; await writeFile(metaPath, JSON.stringify(meta), 'utf-8'); + + // Update the sessions index for ordered browsing + const sessionId = basename(historyDir); + const sessionsDir = dirname(historyDir); + const indexPath = join(sessionsDir, SESSION_INDEX_FILE); + const index = await readSessionIndex(indexPath); + if (!index.some((e) => e.sessionId === sessionId)) { + index.push({ sessionId, displayName: meta.displayName, timestamp: meta.timestamp, sessionType: meta.sessionType }); + await writeFile(indexPath, JSON.stringify(index, null, 2), 'utf-8'); + } } /** @@ -69,6 +91,16 @@ export async function removeCache(agent: ScriptAgent | ProductionAgent): Promise } catch { // already removed or never created } + + // Remove entry from the sessions index + const sessionId = basename(historyDir); + const sessionsDir = dirname(historyDir); + const indexPath = join(sessionsDir, SESSION_INDEX_FILE); + const index = await readSessionIndex(indexPath); + const updated = index.filter((e) => e.sessionId !== sessionId); + if (updated.length !== index.length) { + await writeFile(indexPath, JSON.stringify(updated, null, 2), 'utf-8'); + } } /** @@ -114,7 +146,20 @@ export async function getCurrentSessionId( return ids.length === 1 ? ids[0] : undefined; } -export type CachedSessionEntry = { agentId: string; displayName?: string; sessionIds: string[] }; +export type CachedSessionInfo = { sessionId: string; timestamp?: string; sessionType?: SessionType }; +export type CachedSessionEntry = { agentId: string; displayName?: string; sessions: CachedSessionInfo[] }; + +/** + * Read the sessions index file, returning an empty array if missing or unreadable. + */ +async function readSessionIndex(indexPath: string): Promise { + try { + const raw = await readFile(indexPath, 'utf-8'); + return JSON.parse(raw) as SessionIndex; + } catch { + return []; + } +} /** * List all cached preview sessions in the project, grouped by agent ID. @@ -132,39 +177,61 @@ export async function listCachedSessions(project: SfProject): Promise { const agentId = ent.name; const sessionsDir = join(base, agentId, 'sessions'); - let sessionIds: string[] = []; + let sessions: CachedSessionInfo[] = []; let displayName: string | undefined; try { - const sessionDirs = await readdir(sessionsDir, { withFileTypes: true }); - const withMarker = await Promise.all( - sessionDirs - .filter((s) => s.isDirectory()) - .map(async (s) => { + // Prefer the index for ordered, metadata-rich results + const index = await readSessionIndex(join(sessionsDir, SESSION_INDEX_FILE)); + if (index.length > 0) { + // Verify each indexed session still has its marker file (guard against manual cleanup) + const verified = await Promise.all( + index.map(async (entry) => { try { - await readFile(join(sessionsDir, s.name, SESSION_META_FILE), 'utf-8'); - return s.name; + await readFile(join(sessionsDir, entry.sessionId, SESSION_META_FILE), 'utf-8'); + return entry; } catch { return null; } }) - ); - sessionIds = withMarker.filter((id): id is string => id !== null); - if (sessionIds.length > 0) { - try { - const raw = await readFile(join(sessionsDir, sessionIds[0], SESSION_META_FILE), 'utf-8'); - const meta = JSON.parse(raw) as SessionMeta; - displayName = meta.displayName; - } catch { - // ignore + ); + sessions = verified + .filter((e): e is SessionIndex[number] => e !== null) + .map(({ sessionId, timestamp, sessionType }) => ({ sessionId, timestamp, sessionType })); + displayName = index[0]?.displayName; + } else { + // Fallback: scan directories (no index yet, e.g. sessions started before this feature) + const sessionDirs = await readdir(sessionsDir, { withFileTypes: true }); + const sessionInfos = await Promise.all( + sessionDirs + .filter((s) => s.isDirectory()) + .map(async (s): Promise => { + try { + const raw = await readFile(join(sessionsDir, s.name, SESSION_META_FILE), 'utf-8'); + const meta = JSON.parse(raw) as SessionMeta; + return { sessionId: s.name, timestamp: meta.timestamp, sessionType: meta.sessionType }; + } catch { + return null; + } + }) + ); + sessions = sessionInfos.filter((s): s is CachedSessionInfo => s !== null); + if (sessions.length > 0) { + try { + const raw = await readFile(join(sessionsDir, sessions[0].sessionId, SESSION_META_FILE), 'utf-8'); + const meta = JSON.parse(raw) as SessionMeta; + displayName = meta.displayName; + } catch { + // ignore + } } } } catch { // no sessions dir or unreadable } - return { agentId, displayName, sessionIds }; + return { agentId, displayName, sessions }; }) ); - result.push(...entries.filter((e) => e.sessionIds.length > 0)); + result.push(...entries.filter((e) => e.sessions.length > 0)); } catch { // no agents dir or unreadable } diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts index a1a52e53..b2eaa350 100644 --- a/test/previewSessionStore.test.ts +++ b/test/previewSessionStore.test.ts @@ -15,6 +15,7 @@ */ import { mkdtempSync, rmSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect } from 'chai'; @@ -205,7 +206,7 @@ describe('previewSessionStore', () => { await createCache(agent2); const list = await listCachedSessions(project); expect(list).to.have.lengthOf(2); - const byAgent = Object.fromEntries(list.map((e) => [e.agentId, e.sessionIds])); + const byAgent = Object.fromEntries(list.map((e) => [e.agentId, e.sessions.map((s) => s.sessionId)])); expect(byAgent['bundle-a']).to.have.members(['s1', 's2']); expect(byAgent['bundle-b']).to.deep.equal(['s3']); }); @@ -219,7 +220,78 @@ describe('previewSessionStore', () => { expect(list).to.have.lengthOf(1); expect(list[0].agentId).to.equal('some-id'); expect(list[0].displayName).to.equal('My_Production_Agent'); - expect(list[0].sessionIds).to.deep.equal(['s1']); + expect(list[0].sessions.map((s) => s.sessionId)).to.deep.equal(['s1']); + }); + + it('returns timestamp and sessionType for each session', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'some-id'); + agent.setSessionId('s1'); + await createCache(agent, { sessionType: 'simulated' }); + const list = await listCachedSessions(project); + expect(list[0].sessions[0].sessionType).to.equal('simulated'); + expect(list[0].sessions[0].timestamp) + .to.be.a('string') + .and.match(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('returns sessionType published for production agent sessions', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'prod-agent'); + agent.setSessionId('s1'); + await createCache(agent, { displayName: 'My_Production_Agent', sessionType: 'published' }); + const list = await listCachedSessions(project); + expect(list[0].sessions[0].sessionType).to.equal('published'); + }); + + it('returns sessions in creation order from index', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'bundle-a'); + agent.setSessionId('s1'); + await createCache(agent); + agent.setSessionId('s2'); + await createCache(agent); + agent.setSessionId('s3'); + await createCache(agent); + const list = await listCachedSessions(project); + expect(list[0].sessions.map((s) => s.sessionId)).to.deep.equal(['s1', 's2', 's3']); + }); + + it('index file is written to the sessions directory', async () => { + const agent = makeMockAgent(projectPath, 'bundle-a'); + agent.setSessionId('s1'); + await createCache(agent, { displayName: 'MyAgent', sessionType: 'live' }); + const indexPath = join(projectPath, '.sfdx', 'agents', 'bundle-a', 'sessions', 'index.json'); + const raw = await readFile(indexPath, 'utf-8'); + const index = JSON.parse(raw) as Array<{ sessionId: string; sessionType: string }>; + expect(index).to.have.lengthOf(1); + expect(index[0].sessionId).to.equal('s1'); + expect(index[0].sessionType).to.equal('live'); + }); + + it('removes entry from index when session is ended', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'bundle-a'); + agent.setSessionId('s1'); + await createCache(agent); + agent.setSessionId('s2'); + await createCache(agent); + agent.setSessionId('s1'); + await removeCache(agent); + const list = await listCachedSessions(project); + expect(list[0].sessions.map((s) => s.sessionId)).to.deep.equal(['s2']); + }); + + it('falls back to directory scan when no index exists', async () => { + const project = makeMockProject(() => projectPath); + const agent = makeMockAgent(projectPath, 'bundle-a'); + agent.setSessionId('s1'); + await createCache(agent); + // Remove the index to simulate pre-index sessions + const { unlink: unlinkFn } = await import('node:fs/promises'); + await unlinkFn(join(projectPath, '.sfdx', 'agents', 'bundle-a', 'sessions', 'index.json')); + const list = await listCachedSessions(project); + expect(list[0].sessions.map((s) => s.sessionId)).to.deep.equal(['s1']); }); }); From 66a6d5e13d8a54e6d08a93b843a0c46ced00510e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Rivas?= Date: Fri, 24 Apr 2026 19:25:32 -0300 Subject: [PATCH 2/7] fix: address PR review feedback on session index and type safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix race condition on concurrent createCache/removeCache calls by using atomic write (temp file + rename) via updateSessionIndex helper - Remove export from SessionIndex type — used only internally - Fix sessionType widening: use SessionType union instead of string in AgentPreviewSessionsResult and resolveSessionType return type - Replace inline import() type annotation with top-level named import - Eliminate double-read in fallback dir scan by capturing displayName during the first pass - Update JSON schema to use $ref for SessionType enum Co-Authored-By: Claude Sonnet 4.6 --- schemas/agent-preview-sessions.json | 6 ++- src/commands/agent/preview/sessions.ts | 4 +- src/commands/agent/preview/start.ts | 7 +-- src/previewSessionStore.ts | 68 +++++++++++++++++--------- 4 files changed, 53 insertions(+), 32 deletions(-) diff --git a/schemas/agent-preview-sessions.json b/schemas/agent-preview-sessions.json index 87101e87..168f2e48 100644 --- a/schemas/agent-preview-sessions.json +++ b/schemas/agent-preview-sessions.json @@ -20,12 +20,16 @@ "type": "string" }, "sessionType": { - "type": "string" + "$ref": "#/definitions/SessionType" } }, "required": ["agentId", "sessionId"], "additionalProperties": false } + }, + "SessionType": { + "type": "string", + "enum": ["simulated", "live", "published"] } } } diff --git a/src/commands/agent/preview/sessions.ts b/src/commands/agent/preview/sessions.ts index 8c67ea0c..cdcab638 100644 --- a/src/commands/agent/preview/sessions.ts +++ b/src/commands/agent/preview/sessions.ts @@ -16,7 +16,7 @@ import { SfCommand, toHelpSection } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; -import { listCachedSessions } from '../../../previewSessionStore.js'; +import { listCachedSessions, SessionType } from '../../../previewSessionStore.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.sessions'); @@ -26,7 +26,7 @@ export type AgentPreviewSessionsResult = Array<{ displayName?: string; sessionId: string; timestamp?: string; - sessionType?: string; + sessionType?: SessionType; }>; export default class AgentPreviewSessions extends SfCommand { diff --git a/src/commands/agent/preview/start.ts b/src/commands/agent/preview/start.ts index 38f2bef0..64f5348e 100644 --- a/src/commands/agent/preview/start.ts +++ b/src/commands/agent/preview/start.ts @@ -17,7 +17,7 @@ import { Flags, SfCommand, toHelpSection } from '@salesforce/sf-plugins-core'; import { EnvironmentVariable, Lifecycle, Messages, SfError } from '@salesforce/core'; import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; -import { createCache } from '../../../previewSessionStore.js'; +import { createCache, SessionType } from '../../../previewSessionStore.js'; import { COMPILATION_API_EXIT_CODES } from '../../../common.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); @@ -167,10 +167,7 @@ export default class AgentPreviewStart extends SfCommand e.sessionId === sessionId)) { - index.push({ sessionId, displayName: meta.displayName, timestamp: meta.timestamp, sessionType: meta.sessionType }); - await writeFile(indexPath, JSON.stringify(index, null, 2), 'utf-8'); - } + await updateSessionIndex(indexPath, (index) => { + if (!index.some((e) => e.sessionId === sessionId)) { + index.push({ + sessionId, + displayName: meta.displayName, + timestamp: meta.timestamp, + sessionType: meta.sessionType, + }); + } + return index; + }); } /** @@ -96,11 +102,7 @@ export async function removeCache(agent: ScriptAgent | ProductionAgent): Promise const sessionId = basename(historyDir); const sessionsDir = dirname(historyDir); const indexPath = join(sessionsDir, SESSION_INDEX_FILE); - const index = await readSessionIndex(indexPath); - const updated = index.filter((e) => e.sessionId !== sessionId); - if (updated.length !== index.length) { - await writeFile(indexPath, JSON.stringify(updated, null, 2), 'utf-8'); - } + await updateSessionIndex(indexPath, (index) => index.filter((e) => e.sessionId !== sessionId)); } /** @@ -161,6 +163,20 @@ async function readSessionIndex(indexPath: string): Promise { } } +/** + * Atomically read-modify-write the sessions index. + * Writes to a temp file then renames to avoid partial writes and reduce + * the window for concurrent-write races (last writer wins, no silent drops). + * Propagates errors so callers are aware of index failures. + */ +async function updateSessionIndex(indexPath: string, updater: (index: SessionIndex) => SessionIndex): Promise { + const index = await readSessionIndex(indexPath); + const updated = updater(index); + const tmpPath = `${indexPath}.tmp`; + await writeFile(tmpPath, JSON.stringify(updated, null, 2), 'utf-8'); + await rename(tmpPath, indexPath); +} + /** * List all cached preview sessions in the project, grouped by agent ID. * displayName (when present in session-meta.json) is the authoring bundle name or production agent API name for display. @@ -204,26 +220,30 @@ export async function listCachedSessions(project: SfProject): Promise s.isDirectory()) - .map(async (s): Promise => { + .map(async (s): Promise<(CachedSessionInfo & { displayName?: string }) | null> => { try { const raw = await readFile(join(sessionsDir, s.name, SESSION_META_FILE), 'utf-8'); const meta = JSON.parse(raw) as SessionMeta; - return { sessionId: s.name, timestamp: meta.timestamp, sessionType: meta.sessionType }; + return { + sessionId: s.name, + timestamp: meta.timestamp, + sessionType: meta.sessionType, + displayName: meta.displayName, + }; } catch { return null; } }) ); - sessions = sessionInfos.filter((s): s is CachedSessionInfo => s !== null); - if (sessions.length > 0) { - try { - const raw = await readFile(join(sessionsDir, sessions[0].sessionId, SESSION_META_FILE), 'utf-8'); - const meta = JSON.parse(raw) as SessionMeta; - displayName = meta.displayName; - } catch { - // ignore - } - } + const validSessions = sessionInfos.filter( + (s): s is CachedSessionInfo & { displayName?: string } => s !== null + ); + sessions = validSessions.map(({ sessionId, timestamp, sessionType }) => ({ + sessionId, + timestamp, + sessionType, + })); + displayName = validSessions[0]?.displayName; } } catch { // no sessions dir or unreadable From 764cfa896884ffc2012398df3d26ea827bcc1145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Rivas?= Date: Mon, 27 Apr 2026 10:46:40 -0300 Subject: [PATCH 3/7] fix: move preview session store I/O into @salesforce/agents library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W-22203667 Per review feedback from WillieRuemmele: file I/O for the session cache should live in the library so VS Code extension and CLI share the same storage layer. previewSessionStore.ts is now a thin re-export shim that maps to the new library functions (createPreviewSessionCache → createCache, etc.) so all command callsites remain unchanged. Update @salesforce/agents to local file reference while library PR is in review. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- src/previewSessionStore.ts | 257 ++----------------------------- test/previewSessionStore.test.ts | 10 +- yarn.lock | 4 +- 4 files changed, 21 insertions(+), 252 deletions(-) diff --git a/package.json b/package.json index 3b921081..16fb3e5c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@inquirer/prompts": "^7.10.1", "@oclif/core": "^4", "@oclif/multi-stage-output": "^0.8.36", - "@salesforce/agents": "^1.1.2", + "@salesforce/agents": "file:../agents", "@salesforce/core": "^8.28.3", "@salesforce/kit": "^3.2.6", "@salesforce/sf-plugins-core": "^12.2.6", diff --git a/src/previewSessionStore.ts b/src/previewSessionStore.ts index ddd57fb9..0d17d163 100644 --- a/src/previewSessionStore.ts +++ b/src/previewSessionStore.ts @@ -14,246 +14,19 @@ * limitations under the License. */ -import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; -import { basename, dirname, join } from 'node:path'; -import { SfError } from '@salesforce/core'; -import type { SfProject } from '@salesforce/core'; -import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; - -const SESSION_META_FILE = 'session-meta.json'; -const SESSION_INDEX_FILE = 'index.json'; - -export type SessionType = 'simulated' | 'live' | 'published'; -export type SessionMeta = { displayName?: string; timestamp?: string; sessionType?: SessionType }; -type SessionIndex = Array<{ - sessionId: string; - displayName?: string; - timestamp?: string; - sessionType?: SessionType; -}>; - -/** - * Save a marker so send/end can validate that the session was started for this agent. - * Caller must have started the session (agent has sessionId set). Uses agent.getHistoryDir() for the path. - * Pass displayName (authoring bundle name or production agent API name) so "agent preview sessions" can show it. - */ -export async function createCache( - agent: ScriptAgent | ProductionAgent, - options?: { displayName?: string; sessionType?: SessionType } -): Promise { - const historyDir = await agent.getHistoryDir(); - const metaPath = join(historyDir, SESSION_META_FILE); - const meta: SessionMeta = { - displayName: options?.displayName, - timestamp: new Date().toISOString(), - sessionType: options?.sessionType, - }; - await writeFile(metaPath, JSON.stringify(meta), 'utf-8'); - - // Update the sessions index for ordered browsing - const sessionId = basename(historyDir); - const sessionsDir = dirname(historyDir); - const indexPath = join(sessionsDir, SESSION_INDEX_FILE); - await updateSessionIndex(indexPath, (index) => { - if (!index.some((e) => e.sessionId === sessionId)) { - index.push({ - sessionId, - displayName: meta.displayName, - timestamp: meta.timestamp, - sessionType: meta.sessionType, - }); - } - return index; - }); -} - -/** - * Validate that the session was started for this agent (marker file exists in agent's history dir for current sessionId). - * Caller must set sessionId on the agent (agent.setSessionId) before calling. - * Throws SfError if the session marker is not found. - */ -export async function validatePreviewSession(agent: ScriptAgent | ProductionAgent): Promise { - const historyDir = await agent.getHistoryDir(); - const metaPath = join(historyDir, SESSION_META_FILE); - try { - await readFile(metaPath, 'utf-8'); - } catch { - throw new SfError( - 'No preview session found for this session ID. Run "sf agent preview start" first.', - 'PreviewSessionNotFound' - ); - } -} - -/** - * Remove the session marker so this session is no longer considered "active" for send/end without --session-id. - * Call after ending the session. Caller must set sessionId on the agent before calling. - */ -export async function removeCache(agent: ScriptAgent | ProductionAgent): Promise { - const historyDir = await agent.getHistoryDir(); - const metaPath = join(historyDir, SESSION_META_FILE); - try { - await unlink(metaPath); - } catch { - // already removed or never created - } - - // Remove entry from the sessions index - const sessionId = basename(historyDir); - const sessionsDir = dirname(historyDir); - const indexPath = join(sessionsDir, SESSION_INDEX_FILE); - await updateSessionIndex(indexPath, (index) => index.filter((e) => e.sessionId !== sessionId)); -} - /** - * List session IDs that have a cache marker (started via "agent preview start") for this agent. - * Uses project path and agent's storage ID to find .sfdx/agents//sessions//session-meta.json. - */ -export async function getCachedSessionIds(project: SfProject, agent: ScriptAgent | ProductionAgent): Promise { - const agentId = agent.getAgentIdForStorage(); - const base = join(project.getPath(), '.sfdx'); - const sessionsDir = join(base, 'agents', agentId, 'sessions'); - const sessionIds: string[] = []; - try { - const entries = await readdir(sessionsDir, { withFileTypes: true }); - const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); - const hasMarker = await Promise.all( - dirs.map(async (name) => { - try { - await readFile(join(sessionsDir, name, SESSION_META_FILE), 'utf-8'); - return true; - } catch { - return false; - } - }) - ); - dirs.forEach((name, i) => { - if (hasMarker[i]) sessionIds.push(name); - }); - } catch { - // sessions dir missing or unreadable - } - return sessionIds; -} - -/** - * Return the single "current" session ID when safe: exactly one cached session for this agent. - * Returns undefined when there are zero or multiple sessions (caller should require --session-id). - */ -export async function getCurrentSessionId( - project: SfProject, - agent: ScriptAgent | ProductionAgent -): Promise { - const ids = await getCachedSessionIds(project, agent); - return ids.length === 1 ? ids[0] : undefined; -} - -export type CachedSessionInfo = { sessionId: string; timestamp?: string; sessionType?: SessionType }; -export type CachedSessionEntry = { agentId: string; displayName?: string; sessions: CachedSessionInfo[] }; - -/** - * Read the sessions index file, returning an empty array if missing or unreadable. - */ -async function readSessionIndex(indexPath: string): Promise { - try { - const raw = await readFile(indexPath, 'utf-8'); - return JSON.parse(raw) as SessionIndex; - } catch { - return []; - } -} - -/** - * Atomically read-modify-write the sessions index. - * Writes to a temp file then renames to avoid partial writes and reduce - * the window for concurrent-write races (last writer wins, no silent drops). - * Propagates errors so callers are aware of index failures. - */ -async function updateSessionIndex(indexPath: string, updater: (index: SessionIndex) => SessionIndex): Promise { - const index = await readSessionIndex(indexPath); - const updated = updater(index); - const tmpPath = `${indexPath}.tmp`; - await writeFile(tmpPath, JSON.stringify(updated, null, 2), 'utf-8'); - await rename(tmpPath, indexPath); -} - -/** - * List all cached preview sessions in the project, grouped by agent ID. - * displayName (when present in session-meta.json) is the authoring bundle name or production agent API name for display. - * Use this to show users which sessions exist so they can end or clean up. - */ -export async function listCachedSessions(project: SfProject): Promise { - const base = join(project.getPath(), '.sfdx', 'agents'); - const result: CachedSessionEntry[] = []; - try { - const agentDirs = await readdir(base, { withFileTypes: true }); - const entries = await Promise.all( - agentDirs - .filter((ent) => ent.isDirectory()) - .map(async (ent) => { - const agentId = ent.name; - const sessionsDir = join(base, agentId, 'sessions'); - let sessions: CachedSessionInfo[] = []; - let displayName: string | undefined; - try { - // Prefer the index for ordered, metadata-rich results - const index = await readSessionIndex(join(sessionsDir, SESSION_INDEX_FILE)); - if (index.length > 0) { - // Verify each indexed session still has its marker file (guard against manual cleanup) - const verified = await Promise.all( - index.map(async (entry) => { - try { - await readFile(join(sessionsDir, entry.sessionId, SESSION_META_FILE), 'utf-8'); - return entry; - } catch { - return null; - } - }) - ); - sessions = verified - .filter((e): e is SessionIndex[number] => e !== null) - .map(({ sessionId, timestamp, sessionType }) => ({ sessionId, timestamp, sessionType })); - displayName = index[0]?.displayName; - } else { - // Fallback: scan directories (no index yet, e.g. sessions started before this feature) - const sessionDirs = await readdir(sessionsDir, { withFileTypes: true }); - const sessionInfos = await Promise.all( - sessionDirs - .filter((s) => s.isDirectory()) - .map(async (s): Promise<(CachedSessionInfo & { displayName?: string }) | null> => { - try { - const raw = await readFile(join(sessionsDir, s.name, SESSION_META_FILE), 'utf-8'); - const meta = JSON.parse(raw) as SessionMeta; - return { - sessionId: s.name, - timestamp: meta.timestamp, - sessionType: meta.sessionType, - displayName: meta.displayName, - }; - } catch { - return null; - } - }) - ); - const validSessions = sessionInfos.filter( - (s): s is CachedSessionInfo & { displayName?: string } => s !== null - ); - sessions = validSessions.map(({ sessionId, timestamp, sessionType }) => ({ - sessionId, - timestamp, - sessionType, - })); - displayName = validSessions[0]?.displayName; - } - } catch { - // no sessions dir or unreadable - } - return { agentId, displayName, sessions }; - }) - ); - result.push(...entries.filter((e) => e.sessions.length > 0)); - } catch { - // no agents dir or unreadable - } - return result; -} + * Re-exports the preview session store utilities from @salesforce/agents so that + * the VS Code extension and the CLI share the same storage layer. + */ +export { + createPreviewSessionCache as createCache, + validatePreviewSession, + removePreviewSessionCache as removeCache, + getCachedPreviewSessionIds as getCachedSessionIds, + getCurrentPreviewSessionId as getCurrentSessionId, + listCachedPreviewSessions as listCachedSessions, + type SessionType, + type PreviewSessionMeta as SessionMeta, + type CachedPreviewSessionInfo as CachedSessionInfo, + type CachedPreviewSessionEntry as CachedSessionEntry, +} from '@salesforce/agents'; diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts index b2eaa350..45cbf223 100644 --- a/test/previewSessionStore.test.ts +++ b/test/previewSessionStore.test.ts @@ -19,7 +19,7 @@ import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect } from 'chai'; -import { SfError, SfProject } from '@salesforce/core'; +import { SfProject } from '@salesforce/core'; import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; import { createCache, @@ -94,9 +94,8 @@ describe('previewSessionStore', () => { await validatePreviewSession(agent); expect.fail('Expected validatePreviewSession to throw'); } catch (e) { - expect(e).to.be.instanceOf(SfError); - expect((e as SfError).name).to.equal('PreviewSessionNotFound'); - expect((e as SfError).message).to.include('No preview session found'); + expect((e as Error).name).to.equal('PreviewSessionNotFound'); + expect((e as Error).message).to.include('No preview session found'); } }); @@ -110,8 +109,7 @@ describe('previewSessionStore', () => { await validatePreviewSession(agentB); expect.fail('Expected validatePreviewSession to throw'); } catch (e) { - expect(e).to.be.instanceOf(SfError); - expect((e as SfError).name).to.equal('PreviewSessionNotFound'); + expect((e as Error).name).to.equal('PreviewSessionNotFound'); } }); diff --git a/yarn.lock b/yarn.lock index a01fe617..2309cc34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1593,10 +1593,8 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@salesforce/agents@^1.1.2": +"@salesforce/agents@file:../agents": version "1.1.2" - resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-1.1.2.tgz#aa2b93e0ba71eefcde05541d9add8da3975577ec" - integrity sha512-p7isCk2WoV0t1skRoTjYeead+GOoF2I7VPo+K6YYt+h6S+v8vJTdBc8NNhnmUJcz386FIK5jc0g7bSz8lCQ0tQ== dependencies: "@salesforce/core" "^8.28.3" "@salesforce/kit" "^3.2.6" From 57fb5f7c5a7b0dd9b6f44bc2766d3ef05035083c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Rivas?= Date: Mon, 27 Apr 2026 11:01:33 -0300 Subject: [PATCH 4/7] fix: revert to inline session store until agents library PR ships The file:../agents dependency breaks CI since runners only clone this repo. Restore the full inline implementation and ^1.1.2 semver until forcedotcom/agents#269 merges and a new version is published, at which point previewSessionStore.ts can become the thin shim again. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- src/previewSessionStore.ts | 257 +++++++++++++++++++++++++++++-- test/previewSessionStore.test.ts | 10 +- yarn.lock | 6 +- 4 files changed, 253 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 16fb3e5c..3b921081 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@inquirer/prompts": "^7.10.1", "@oclif/core": "^4", "@oclif/multi-stage-output": "^0.8.36", - "@salesforce/agents": "file:../agents", + "@salesforce/agents": "^1.1.2", "@salesforce/core": "^8.28.3", "@salesforce/kit": "^3.2.6", "@salesforce/sf-plugins-core": "^12.2.6", diff --git a/src/previewSessionStore.ts b/src/previewSessionStore.ts index 0d17d163..ddd57fb9 100644 --- a/src/previewSessionStore.ts +++ b/src/previewSessionStore.ts @@ -14,19 +14,246 @@ * limitations under the License. */ +import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { SfError } from '@salesforce/core'; +import type { SfProject } from '@salesforce/core'; +import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; + +const SESSION_META_FILE = 'session-meta.json'; +const SESSION_INDEX_FILE = 'index.json'; + +export type SessionType = 'simulated' | 'live' | 'published'; +export type SessionMeta = { displayName?: string; timestamp?: string; sessionType?: SessionType }; +type SessionIndex = Array<{ + sessionId: string; + displayName?: string; + timestamp?: string; + sessionType?: SessionType; +}>; + +/** + * Save a marker so send/end can validate that the session was started for this agent. + * Caller must have started the session (agent has sessionId set). Uses agent.getHistoryDir() for the path. + * Pass displayName (authoring bundle name or production agent API name) so "agent preview sessions" can show it. + */ +export async function createCache( + agent: ScriptAgent | ProductionAgent, + options?: { displayName?: string; sessionType?: SessionType } +): Promise { + const historyDir = await agent.getHistoryDir(); + const metaPath = join(historyDir, SESSION_META_FILE); + const meta: SessionMeta = { + displayName: options?.displayName, + timestamp: new Date().toISOString(), + sessionType: options?.sessionType, + }; + await writeFile(metaPath, JSON.stringify(meta), 'utf-8'); + + // Update the sessions index for ordered browsing + const sessionId = basename(historyDir); + const sessionsDir = dirname(historyDir); + const indexPath = join(sessionsDir, SESSION_INDEX_FILE); + await updateSessionIndex(indexPath, (index) => { + if (!index.some((e) => e.sessionId === sessionId)) { + index.push({ + sessionId, + displayName: meta.displayName, + timestamp: meta.timestamp, + sessionType: meta.sessionType, + }); + } + return index; + }); +} + +/** + * Validate that the session was started for this agent (marker file exists in agent's history dir for current sessionId). + * Caller must set sessionId on the agent (agent.setSessionId) before calling. + * Throws SfError if the session marker is not found. + */ +export async function validatePreviewSession(agent: ScriptAgent | ProductionAgent): Promise { + const historyDir = await agent.getHistoryDir(); + const metaPath = join(historyDir, SESSION_META_FILE); + try { + await readFile(metaPath, 'utf-8'); + } catch { + throw new SfError( + 'No preview session found for this session ID. Run "sf agent preview start" first.', + 'PreviewSessionNotFound' + ); + } +} + +/** + * Remove the session marker so this session is no longer considered "active" for send/end without --session-id. + * Call after ending the session. Caller must set sessionId on the agent before calling. + */ +export async function removeCache(agent: ScriptAgent | ProductionAgent): Promise { + const historyDir = await agent.getHistoryDir(); + const metaPath = join(historyDir, SESSION_META_FILE); + try { + await unlink(metaPath); + } catch { + // already removed or never created + } + + // Remove entry from the sessions index + const sessionId = basename(historyDir); + const sessionsDir = dirname(historyDir); + const indexPath = join(sessionsDir, SESSION_INDEX_FILE); + await updateSessionIndex(indexPath, (index) => index.filter((e) => e.sessionId !== sessionId)); +} + /** - * Re-exports the preview session store utilities from @salesforce/agents so that - * the VS Code extension and the CLI share the same storage layer. - */ -export { - createPreviewSessionCache as createCache, - validatePreviewSession, - removePreviewSessionCache as removeCache, - getCachedPreviewSessionIds as getCachedSessionIds, - getCurrentPreviewSessionId as getCurrentSessionId, - listCachedPreviewSessions as listCachedSessions, - type SessionType, - type PreviewSessionMeta as SessionMeta, - type CachedPreviewSessionInfo as CachedSessionInfo, - type CachedPreviewSessionEntry as CachedSessionEntry, -} from '@salesforce/agents'; + * List session IDs that have a cache marker (started via "agent preview start") for this agent. + * Uses project path and agent's storage ID to find .sfdx/agents//sessions//session-meta.json. + */ +export async function getCachedSessionIds(project: SfProject, agent: ScriptAgent | ProductionAgent): Promise { + const agentId = agent.getAgentIdForStorage(); + const base = join(project.getPath(), '.sfdx'); + const sessionsDir = join(base, 'agents', agentId, 'sessions'); + const sessionIds: string[] = []; + try { + const entries = await readdir(sessionsDir, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + const hasMarker = await Promise.all( + dirs.map(async (name) => { + try { + await readFile(join(sessionsDir, name, SESSION_META_FILE), 'utf-8'); + return true; + } catch { + return false; + } + }) + ); + dirs.forEach((name, i) => { + if (hasMarker[i]) sessionIds.push(name); + }); + } catch { + // sessions dir missing or unreadable + } + return sessionIds; +} + +/** + * Return the single "current" session ID when safe: exactly one cached session for this agent. + * Returns undefined when there are zero or multiple sessions (caller should require --session-id). + */ +export async function getCurrentSessionId( + project: SfProject, + agent: ScriptAgent | ProductionAgent +): Promise { + const ids = await getCachedSessionIds(project, agent); + return ids.length === 1 ? ids[0] : undefined; +} + +export type CachedSessionInfo = { sessionId: string; timestamp?: string; sessionType?: SessionType }; +export type CachedSessionEntry = { agentId: string; displayName?: string; sessions: CachedSessionInfo[] }; + +/** + * Read the sessions index file, returning an empty array if missing or unreadable. + */ +async function readSessionIndex(indexPath: string): Promise { + try { + const raw = await readFile(indexPath, 'utf-8'); + return JSON.parse(raw) as SessionIndex; + } catch { + return []; + } +} + +/** + * Atomically read-modify-write the sessions index. + * Writes to a temp file then renames to avoid partial writes and reduce + * the window for concurrent-write races (last writer wins, no silent drops). + * Propagates errors so callers are aware of index failures. + */ +async function updateSessionIndex(indexPath: string, updater: (index: SessionIndex) => SessionIndex): Promise { + const index = await readSessionIndex(indexPath); + const updated = updater(index); + const tmpPath = `${indexPath}.tmp`; + await writeFile(tmpPath, JSON.stringify(updated, null, 2), 'utf-8'); + await rename(tmpPath, indexPath); +} + +/** + * List all cached preview sessions in the project, grouped by agent ID. + * displayName (when present in session-meta.json) is the authoring bundle name or production agent API name for display. + * Use this to show users which sessions exist so they can end or clean up. + */ +export async function listCachedSessions(project: SfProject): Promise { + const base = join(project.getPath(), '.sfdx', 'agents'); + const result: CachedSessionEntry[] = []; + try { + const agentDirs = await readdir(base, { withFileTypes: true }); + const entries = await Promise.all( + agentDirs + .filter((ent) => ent.isDirectory()) + .map(async (ent) => { + const agentId = ent.name; + const sessionsDir = join(base, agentId, 'sessions'); + let sessions: CachedSessionInfo[] = []; + let displayName: string | undefined; + try { + // Prefer the index for ordered, metadata-rich results + const index = await readSessionIndex(join(sessionsDir, SESSION_INDEX_FILE)); + if (index.length > 0) { + // Verify each indexed session still has its marker file (guard against manual cleanup) + const verified = await Promise.all( + index.map(async (entry) => { + try { + await readFile(join(sessionsDir, entry.sessionId, SESSION_META_FILE), 'utf-8'); + return entry; + } catch { + return null; + } + }) + ); + sessions = verified + .filter((e): e is SessionIndex[number] => e !== null) + .map(({ sessionId, timestamp, sessionType }) => ({ sessionId, timestamp, sessionType })); + displayName = index[0]?.displayName; + } else { + // Fallback: scan directories (no index yet, e.g. sessions started before this feature) + const sessionDirs = await readdir(sessionsDir, { withFileTypes: true }); + const sessionInfos = await Promise.all( + sessionDirs + .filter((s) => s.isDirectory()) + .map(async (s): Promise<(CachedSessionInfo & { displayName?: string }) | null> => { + try { + const raw = await readFile(join(sessionsDir, s.name, SESSION_META_FILE), 'utf-8'); + const meta = JSON.parse(raw) as SessionMeta; + return { + sessionId: s.name, + timestamp: meta.timestamp, + sessionType: meta.sessionType, + displayName: meta.displayName, + }; + } catch { + return null; + } + }) + ); + const validSessions = sessionInfos.filter( + (s): s is CachedSessionInfo & { displayName?: string } => s !== null + ); + sessions = validSessions.map(({ sessionId, timestamp, sessionType }) => ({ + sessionId, + timestamp, + sessionType, + })); + displayName = validSessions[0]?.displayName; + } + } catch { + // no sessions dir or unreadable + } + return { agentId, displayName, sessions }; + }) + ); + result.push(...entries.filter((e) => e.sessions.length > 0)); + } catch { + // no agents dir or unreadable + } + return result; +} diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts index 45cbf223..b2eaa350 100644 --- a/test/previewSessionStore.test.ts +++ b/test/previewSessionStore.test.ts @@ -19,7 +19,7 @@ import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect } from 'chai'; -import { SfProject } from '@salesforce/core'; +import { SfError, SfProject } from '@salesforce/core'; import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; import { createCache, @@ -94,8 +94,9 @@ describe('previewSessionStore', () => { await validatePreviewSession(agent); expect.fail('Expected validatePreviewSession to throw'); } catch (e) { - expect((e as Error).name).to.equal('PreviewSessionNotFound'); - expect((e as Error).message).to.include('No preview session found'); + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionNotFound'); + expect((e as SfError).message).to.include('No preview session found'); } }); @@ -109,7 +110,8 @@ describe('previewSessionStore', () => { await validatePreviewSession(agentB); expect.fail('Expected validatePreviewSession to throw'); } catch (e) { - expect((e as Error).name).to.equal('PreviewSessionNotFound'); + expect(e).to.be.instanceOf(SfError); + expect((e as SfError).name).to.equal('PreviewSessionNotFound'); } }); diff --git a/yarn.lock b/yarn.lock index 2309cc34..0bf1d9bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1593,8 +1593,10 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@salesforce/agents@file:../agents": - version "1.1.2" +"@salesforce/agents@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-1.1.3.tgz#819888cbf3a84789a450b063ea5bf1ce70293a37" + integrity sha512-WdYoSUXJ2yCN2wXsl3wWoSkjAUZW0j7/baWtpz2uO5dKlD8vQlIrAxPTZHvAxSiqi6mMC5TMGPptjhVol5AsEA== dependencies: "@salesforce/core" "^8.28.3" "@salesforce/kit" "^3.2.6" From f3ebef858f8414ac3fc6822eaceabec5111e51a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Rivas?= Date: Mon, 27 Apr 2026 12:52:46 -0300 Subject: [PATCH 5/7] refactor: replace session store inline impl with shim to @salesforce/agents All session store logic now lives in the agents library (forcedotcom/agents#270). This file is a thin re-export shim; the plugin-agent tests are removed since the library ships its own test coverage. CI will fail until agents#270 merges and the dependency is updated to the published semver. Co-Authored-By: Claude Sonnet 4.6 --- src/previewSessionStore.ts | 255 ++---------------------- test/previewSessionStore.test.ts | 326 ------------------------------- 2 files changed, 12 insertions(+), 569 deletions(-) delete mode 100644 test/previewSessionStore.test.ts diff --git a/src/previewSessionStore.ts b/src/previewSessionStore.ts index ddd57fb9..eb926372 100644 --- a/src/previewSessionStore.ts +++ b/src/previewSessionStore.ts @@ -14,246 +14,15 @@ * limitations under the License. */ -import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; -import { basename, dirname, join } from 'node:path'; -import { SfError } from '@salesforce/core'; -import type { SfProject } from '@salesforce/core'; -import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; - -const SESSION_META_FILE = 'session-meta.json'; -const SESSION_INDEX_FILE = 'index.json'; - -export type SessionType = 'simulated' | 'live' | 'published'; -export type SessionMeta = { displayName?: string; timestamp?: string; sessionType?: SessionType }; -type SessionIndex = Array<{ - sessionId: string; - displayName?: string; - timestamp?: string; - sessionType?: SessionType; -}>; - -/** - * Save a marker so send/end can validate that the session was started for this agent. - * Caller must have started the session (agent has sessionId set). Uses agent.getHistoryDir() for the path. - * Pass displayName (authoring bundle name or production agent API name) so "agent preview sessions" can show it. - */ -export async function createCache( - agent: ScriptAgent | ProductionAgent, - options?: { displayName?: string; sessionType?: SessionType } -): Promise { - const historyDir = await agent.getHistoryDir(); - const metaPath = join(historyDir, SESSION_META_FILE); - const meta: SessionMeta = { - displayName: options?.displayName, - timestamp: new Date().toISOString(), - sessionType: options?.sessionType, - }; - await writeFile(metaPath, JSON.stringify(meta), 'utf-8'); - - // Update the sessions index for ordered browsing - const sessionId = basename(historyDir); - const sessionsDir = dirname(historyDir); - const indexPath = join(sessionsDir, SESSION_INDEX_FILE); - await updateSessionIndex(indexPath, (index) => { - if (!index.some((e) => e.sessionId === sessionId)) { - index.push({ - sessionId, - displayName: meta.displayName, - timestamp: meta.timestamp, - sessionType: meta.sessionType, - }); - } - return index; - }); -} - -/** - * Validate that the session was started for this agent (marker file exists in agent's history dir for current sessionId). - * Caller must set sessionId on the agent (agent.setSessionId) before calling. - * Throws SfError if the session marker is not found. - */ -export async function validatePreviewSession(agent: ScriptAgent | ProductionAgent): Promise { - const historyDir = await agent.getHistoryDir(); - const metaPath = join(historyDir, SESSION_META_FILE); - try { - await readFile(metaPath, 'utf-8'); - } catch { - throw new SfError( - 'No preview session found for this session ID. Run "sf agent preview start" first.', - 'PreviewSessionNotFound' - ); - } -} - -/** - * Remove the session marker so this session is no longer considered "active" for send/end without --session-id. - * Call after ending the session. Caller must set sessionId on the agent before calling. - */ -export async function removeCache(agent: ScriptAgent | ProductionAgent): Promise { - const historyDir = await agent.getHistoryDir(); - const metaPath = join(historyDir, SESSION_META_FILE); - try { - await unlink(metaPath); - } catch { - // already removed or never created - } - - // Remove entry from the sessions index - const sessionId = basename(historyDir); - const sessionsDir = dirname(historyDir); - const indexPath = join(sessionsDir, SESSION_INDEX_FILE); - await updateSessionIndex(indexPath, (index) => index.filter((e) => e.sessionId !== sessionId)); -} - -/** - * List session IDs that have a cache marker (started via "agent preview start") for this agent. - * Uses project path and agent's storage ID to find .sfdx/agents//sessions//session-meta.json. - */ -export async function getCachedSessionIds(project: SfProject, agent: ScriptAgent | ProductionAgent): Promise { - const agentId = agent.getAgentIdForStorage(); - const base = join(project.getPath(), '.sfdx'); - const sessionsDir = join(base, 'agents', agentId, 'sessions'); - const sessionIds: string[] = []; - try { - const entries = await readdir(sessionsDir, { withFileTypes: true }); - const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); - const hasMarker = await Promise.all( - dirs.map(async (name) => { - try { - await readFile(join(sessionsDir, name, SESSION_META_FILE), 'utf-8'); - return true; - } catch { - return false; - } - }) - ); - dirs.forEach((name, i) => { - if (hasMarker[i]) sessionIds.push(name); - }); - } catch { - // sessions dir missing or unreadable - } - return sessionIds; -} - -/** - * Return the single "current" session ID when safe: exactly one cached session for this agent. - * Returns undefined when there are zero or multiple sessions (caller should require --session-id). - */ -export async function getCurrentSessionId( - project: SfProject, - agent: ScriptAgent | ProductionAgent -): Promise { - const ids = await getCachedSessionIds(project, agent); - return ids.length === 1 ? ids[0] : undefined; -} - -export type CachedSessionInfo = { sessionId: string; timestamp?: string; sessionType?: SessionType }; -export type CachedSessionEntry = { agentId: string; displayName?: string; sessions: CachedSessionInfo[] }; - -/** - * Read the sessions index file, returning an empty array if missing or unreadable. - */ -async function readSessionIndex(indexPath: string): Promise { - try { - const raw = await readFile(indexPath, 'utf-8'); - return JSON.parse(raw) as SessionIndex; - } catch { - return []; - } -} - -/** - * Atomically read-modify-write the sessions index. - * Writes to a temp file then renames to avoid partial writes and reduce - * the window for concurrent-write races (last writer wins, no silent drops). - * Propagates errors so callers are aware of index failures. - */ -async function updateSessionIndex(indexPath: string, updater: (index: SessionIndex) => SessionIndex): Promise { - const index = await readSessionIndex(indexPath); - const updated = updater(index); - const tmpPath = `${indexPath}.tmp`; - await writeFile(tmpPath, JSON.stringify(updated, null, 2), 'utf-8'); - await rename(tmpPath, indexPath); -} - -/** - * List all cached preview sessions in the project, grouped by agent ID. - * displayName (when present in session-meta.json) is the authoring bundle name or production agent API name for display. - * Use this to show users which sessions exist so they can end or clean up. - */ -export async function listCachedSessions(project: SfProject): Promise { - const base = join(project.getPath(), '.sfdx', 'agents'); - const result: CachedSessionEntry[] = []; - try { - const agentDirs = await readdir(base, { withFileTypes: true }); - const entries = await Promise.all( - agentDirs - .filter((ent) => ent.isDirectory()) - .map(async (ent) => { - const agentId = ent.name; - const sessionsDir = join(base, agentId, 'sessions'); - let sessions: CachedSessionInfo[] = []; - let displayName: string | undefined; - try { - // Prefer the index for ordered, metadata-rich results - const index = await readSessionIndex(join(sessionsDir, SESSION_INDEX_FILE)); - if (index.length > 0) { - // Verify each indexed session still has its marker file (guard against manual cleanup) - const verified = await Promise.all( - index.map(async (entry) => { - try { - await readFile(join(sessionsDir, entry.sessionId, SESSION_META_FILE), 'utf-8'); - return entry; - } catch { - return null; - } - }) - ); - sessions = verified - .filter((e): e is SessionIndex[number] => e !== null) - .map(({ sessionId, timestamp, sessionType }) => ({ sessionId, timestamp, sessionType })); - displayName = index[0]?.displayName; - } else { - // Fallback: scan directories (no index yet, e.g. sessions started before this feature) - const sessionDirs = await readdir(sessionsDir, { withFileTypes: true }); - const sessionInfos = await Promise.all( - sessionDirs - .filter((s) => s.isDirectory()) - .map(async (s): Promise<(CachedSessionInfo & { displayName?: string }) | null> => { - try { - const raw = await readFile(join(sessionsDir, s.name, SESSION_META_FILE), 'utf-8'); - const meta = JSON.parse(raw) as SessionMeta; - return { - sessionId: s.name, - timestamp: meta.timestamp, - sessionType: meta.sessionType, - displayName: meta.displayName, - }; - } catch { - return null; - } - }) - ); - const validSessions = sessionInfos.filter( - (s): s is CachedSessionInfo & { displayName?: string } => s !== null - ); - sessions = validSessions.map(({ sessionId, timestamp, sessionType }) => ({ - sessionId, - timestamp, - sessionType, - })); - displayName = validSessions[0]?.displayName; - } - } catch { - // no sessions dir or unreadable - } - return { agentId, displayName, sessions }; - }) - ); - result.push(...entries.filter((e) => e.sessions.length > 0)); - } catch { - // no agents dir or unreadable - } - return result; -} +export { + createPreviewSessionCache as createCache, + validatePreviewSession, + removePreviewSessionCache as removeCache, + getCachedPreviewSessionIds as getCachedSessionIds, + getCurrentPreviewSessionId as getCurrentSessionId, + listCachedPreviewSessions as listCachedSessions, + type SessionType, + type PreviewSessionMeta, + type CachedPreviewSessionInfo, + type CachedPreviewSessionEntry, +} from '@salesforce/agents'; diff --git a/test/previewSessionStore.test.ts b/test/previewSessionStore.test.ts deleted file mode 100644 index b2eaa350..00000000 --- a/test/previewSessionStore.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -/* - * Copyright 2026, 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 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { mkdtempSync, rmSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { expect } from 'chai'; -import { SfError, SfProject } from '@salesforce/core'; -import type { ProductionAgent, ScriptAgent } from '@salesforce/agents'; -import { - createCache, - getCachedSessionIds, - getCurrentSessionId, - listCachedSessions, - removeCache, - validatePreviewSession, -} from '../src/previewSessionStore.js'; - -function makeMockProject(getPath: () => string): SfProject { - return { getPath } as SfProject; -} - -function makeMockAgent(projectDir: string, agentId: string): ScriptAgent | ProductionAgent { - let sessionId: string | undefined; - return { - setSessionId(id: string) { - sessionId = id; - }, - getAgentIdForStorage(): string { - return agentId; - }, - async getHistoryDir(): Promise { - if (!sessionId) throw new Error('sessionId not set'); - const dir = join(projectDir, '.sfdx', 'agents', agentId, 'sessions', sessionId); - const { mkdir } = await import('node:fs/promises'); - await mkdir(dir, { recursive: true }); - return dir; - }, - } as ScriptAgent | ProductionAgent; -} - -describe('previewSessionStore', () => { - let projectPath: string; - - beforeEach(() => { - projectPath = mkdtempSync(join(tmpdir(), 'preview-session-store-')); - }); - - afterEach(() => { - rmSync(projectPath, { recursive: true, force: true }); - }); - - describe('createCache', () => { - it('saves session and validates with same agent', async () => { - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('sess-1'); - await createCache(agent); - agent.setSessionId('sess-1'); - await validatePreviewSession(agent); - }); - - it('allows multiple sessions for same agent', async () => { - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('sess-a'); - await createCache(agent); - agent.setSessionId('sess-b'); - await createCache(agent); - agent.setSessionId('sess-a'); - await validatePreviewSession(agent); - agent.setSessionId('sess-b'); - await validatePreviewSession(agent); - }); - }); - - describe('validatePreviewSession', () => { - it('throws PreviewSessionNotFound when session file does not exist', async () => { - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('unknown-sess'); - try { - await validatePreviewSession(agent); - expect.fail('Expected validatePreviewSession to throw'); - } catch (e) { - expect(e).to.be.instanceOf(SfError); - expect((e as SfError).name).to.equal('PreviewSessionNotFound'); - expect((e as SfError).message).to.include('No preview session found'); - } - }); - - it('throws PreviewSessionNotFound when session id is for different agent', async () => { - const agentA = makeMockAgent(projectPath, 'agent-a'); - const agentB = makeMockAgent(projectPath, 'agent-b'); - agentA.setSessionId('sess-1'); - await createCache(agentA); - agentB.setSessionId('sess-1'); - try { - await validatePreviewSession(agentB); - expect.fail('Expected validatePreviewSession to throw'); - } catch (e) { - expect(e).to.be.instanceOf(SfError); - expect((e as SfError).name).to.equal('PreviewSessionNotFound'); - } - }); - - it('succeeds when session exists for this agent', async () => { - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('sess-1'); - await createCache(agent); - agent.setSessionId('sess-1'); - await validatePreviewSession(agent); - }); - }); - - describe('getCachedSessionIds', () => { - it('returns empty when no sessions', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'agent-1'); - const ids = await getCachedSessionIds(project, agent); - expect(ids).to.deep.equal([]); - }); - - it('returns session ids that have session-meta.json', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('sess-1'); - await createCache(agent); - agent.setSessionId('sess-2'); - await createCache(agent); - const ids = await getCachedSessionIds(project, agent); - expect(ids).to.have.members(['sess-1', 'sess-2']); - }); - - it('does not return session dirs without session-meta.json', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('sess-1'); - await createCache(agent); - const { mkdir } = await import('node:fs/promises'); - await mkdir(join(projectPath, '.sfdx', 'agents', 'agent-1', 'sessions', 'other-dir'), { - recursive: true, - }); - const ids = await getCachedSessionIds(project, agent); - expect(ids).to.deep.equal(['sess-1']); - }); - }); - - describe('removeCache', () => { - it('removes session from cache so getCachedSessionIds no longer includes it', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('sess-1'); - await createCache(agent); - agent.setSessionId('sess-2'); - await createCache(agent); - let ids = await getCachedSessionIds(project, agent); - expect(ids).to.have.members(['sess-1', 'sess-2']); - agent.setSessionId('sess-1'); - await removeCache(agent); - ids = await getCachedSessionIds(project, agent); - expect(ids).to.deep.equal(['sess-2']); - }); - - it('after removing one of two sessions, getCurrentSessionId returns the remaining session', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('sess-a'); - await createCache(agent); - agent.setSessionId('sess-b'); - await createCache(agent); - expect(await getCurrentSessionId(project, agent)).to.be.undefined; - agent.setSessionId('sess-a'); - await removeCache(agent); - expect(await getCurrentSessionId(project, agent)).to.equal('sess-b'); - }); - }); - - describe('listCachedSessions', () => { - it('returns empty when no cached sessions', async () => { - const project = makeMockProject(() => projectPath); - const list = await listCachedSessions(project); - expect(list).to.deep.equal([]); - }); - - it('returns agent ids and session ids for all cached sessions', async () => { - const project = makeMockProject(() => projectPath); - const agent1 = makeMockAgent(projectPath, 'bundle-a'); - agent1.setSessionId('s1'); - await createCache(agent1); - agent1.setSessionId('s2'); - await createCache(agent1); - const agent2 = makeMockAgent(projectPath, 'bundle-b'); - agent2.setSessionId('s3'); - await createCache(agent2); - const list = await listCachedSessions(project); - expect(list).to.have.lengthOf(2); - const byAgent = Object.fromEntries(list.map((e) => [e.agentId, e.sessions.map((s) => s.sessionId)])); - expect(byAgent['bundle-a']).to.have.members(['s1', 's2']); - expect(byAgent['bundle-b']).to.deep.equal(['s3']); - }); - - it('returns displayName from session-meta when createCache was called with displayName', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'some-id'); - agent.setSessionId('s1'); - await createCache(agent, { displayName: 'My_Production_Agent' }); - const list = await listCachedSessions(project); - expect(list).to.have.lengthOf(1); - expect(list[0].agentId).to.equal('some-id'); - expect(list[0].displayName).to.equal('My_Production_Agent'); - expect(list[0].sessions.map((s) => s.sessionId)).to.deep.equal(['s1']); - }); - - it('returns timestamp and sessionType for each session', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'some-id'); - agent.setSessionId('s1'); - await createCache(agent, { sessionType: 'simulated' }); - const list = await listCachedSessions(project); - expect(list[0].sessions[0].sessionType).to.equal('simulated'); - expect(list[0].sessions[0].timestamp) - .to.be.a('string') - .and.match(/^\d{4}-\d{2}-\d{2}T/); - }); - - it('returns sessionType published for production agent sessions', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'prod-agent'); - agent.setSessionId('s1'); - await createCache(agent, { displayName: 'My_Production_Agent', sessionType: 'published' }); - const list = await listCachedSessions(project); - expect(list[0].sessions[0].sessionType).to.equal('published'); - }); - - it('returns sessions in creation order from index', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'bundle-a'); - agent.setSessionId('s1'); - await createCache(agent); - agent.setSessionId('s2'); - await createCache(agent); - agent.setSessionId('s3'); - await createCache(agent); - const list = await listCachedSessions(project); - expect(list[0].sessions.map((s) => s.sessionId)).to.deep.equal(['s1', 's2', 's3']); - }); - - it('index file is written to the sessions directory', async () => { - const agent = makeMockAgent(projectPath, 'bundle-a'); - agent.setSessionId('s1'); - await createCache(agent, { displayName: 'MyAgent', sessionType: 'live' }); - const indexPath = join(projectPath, '.sfdx', 'agents', 'bundle-a', 'sessions', 'index.json'); - const raw = await readFile(indexPath, 'utf-8'); - const index = JSON.parse(raw) as Array<{ sessionId: string; sessionType: string }>; - expect(index).to.have.lengthOf(1); - expect(index[0].sessionId).to.equal('s1'); - expect(index[0].sessionType).to.equal('live'); - }); - - it('removes entry from index when session is ended', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'bundle-a'); - agent.setSessionId('s1'); - await createCache(agent); - agent.setSessionId('s2'); - await createCache(agent); - agent.setSessionId('s1'); - await removeCache(agent); - const list = await listCachedSessions(project); - expect(list[0].sessions.map((s) => s.sessionId)).to.deep.equal(['s2']); - }); - - it('falls back to directory scan when no index exists', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'bundle-a'); - agent.setSessionId('s1'); - await createCache(agent); - // Remove the index to simulate pre-index sessions - const { unlink: unlinkFn } = await import('node:fs/promises'); - await unlinkFn(join(projectPath, '.sfdx', 'agents', 'bundle-a', 'sessions', 'index.json')); - const list = await listCachedSessions(project); - expect(list[0].sessions.map((s) => s.sessionId)).to.deep.equal(['s1']); - }); - }); - - describe('getCurrentSessionId', () => { - it('returns undefined when no sessions', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'agent-1'); - const id = await getCurrentSessionId(project, agent); - expect(id).to.be.undefined; - }); - - it('returns session id when exactly one session', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('sess-1'); - await createCache(agent); - const id = await getCurrentSessionId(project, agent); - expect(id).to.equal('sess-1'); - }); - - it('returns undefined when multiple sessions', async () => { - const project = makeMockProject(() => projectPath); - const agent = makeMockAgent(projectPath, 'agent-1'); - agent.setSessionId('sess-a'); - await createCache(agent); - agent.setSessionId('sess-b'); - await createCache(agent); - const id = await getCurrentSessionId(project, agent); - expect(id).to.be.undefined; - }); - }); -}); From 4cb41d3e96b6bb796b3fc7b15b7668a19249ef9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Rivas?= Date: Mon, 27 Apr 2026 13:00:22 -0300 Subject: [PATCH 6/7] chore: revert unintended yarn.lock bump of @salesforce/agents to 1.1.3 Co-Authored-By: Claude Sonnet 4.6 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0bf1d9bc..a01fe617 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1594,9 +1594,9 @@ integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== "@salesforce/agents@^1.1.2": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-1.1.3.tgz#819888cbf3a84789a450b063ea5bf1ce70293a37" - integrity sha512-WdYoSUXJ2yCN2wXsl3wWoSkjAUZW0j7/baWtpz2uO5dKlD8vQlIrAxPTZHvAxSiqi6mMC5TMGPptjhVol5AsEA== + version "1.1.2" + resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-1.1.2.tgz#aa2b93e0ba71eefcde05541d9add8da3975577ec" + integrity sha512-p7isCk2WoV0t1skRoTjYeead+GOoF2I7VPo+K6YYt+h6S+v8vJTdBc8NNhnmUJcz386FIK5jc0g7bSz8lCQ0tQ== dependencies: "@salesforce/core" "^8.28.3" "@salesforce/kit" "^3.2.6" From cb042fd0037e669c42a17203c0391fad47d0adf0 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Mon, 27 Apr 2026 15:10:25 -0600 Subject: [PATCH 7/7] chore: bump agents --- package.json | 2 +- src/commands/agent/preview/start.ts | 2 +- yarn.lock | 565 ++++++++++++++-------------- 3 files changed, 281 insertions(+), 288 deletions(-) diff --git a/package.json b/package.json index 3b921081..0ca9552d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@inquirer/prompts": "^7.10.1", "@oclif/core": "^4", "@oclif/multi-stage-output": "^0.8.36", - "@salesforce/agents": "^1.1.2", + "@salesforce/agents": "^1.2.0", "@salesforce/core": "^8.28.3", "@salesforce/kit": "^3.2.6", "@salesforce/sf-plugins-core": "^12.2.6", diff --git a/src/commands/agent/preview/start.ts b/src/commands/agent/preview/start.ts index 64f5348e..ef64cb2c 100644 --- a/src/commands/agent/preview/start.ts +++ b/src/commands/agent/preview/start.ts @@ -87,7 +87,7 @@ export default class AgentPreviewStart extends SfCommand