diff --git a/command-snapshot.json b/command-snapshot.json index e5583d25..966b1302 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -241,6 +241,14 @@ ], "plugin": "@salesforce/plugin-agent" }, + { + "alias": [], + "command": "agent:trace:delete", + "flagAliases": [], + "flagChars": ["a"], + "flags": ["agent", "flags-dir", "json", "no-prompt", "older-than", "session-id"], + "plugin": "@salesforce/plugin-agent" + }, { "alias": [], "command": "agent:trace:list", diff --git a/messages/agent.trace.delete.md b/messages/agent.trace.delete.md new file mode 100644 index 00000000..32f23c6d --- /dev/null +++ b/messages/agent.trace.delete.md @@ -0,0 +1,87 @@ +# summary + +Delete agent preview trace files. + +# description + +Deletes trace files recorded during agent preview sessions. By default, shows a preview of what will be deleted and prompts for confirmation. Use --no-prompt to skip confirmation. + +Without filters, deletes all traces for all agents and sessions. Use flags to narrow the scope: filter by agent name (--agent), by session (--session-id), or by age (--older-than). + +# flags.agent.summary + +Only delete traces for this agent name (substring match). Matches against the name used when starting the session, whether that's an authoring bundle or a published agent API name. + +# flags.session-id.summary + +Only delete traces from this session ID. + +# flags.older-than.summary + +Only delete traces older than this duration. Accepts a number followed by a unit: m/minutes, h/hours, d/days, w/weeks (e.g. 7d, 24h, 2w). + +# flags.no-prompt.summary + +Skip the confirmation prompt and delete immediately. + +# error.invalidOlderThan + +Invalid --older-than value: '%s'. Use a number followed by a unit: m/minutes, h/hours, d/days, w/weeks (e.g. 7d, 24h, 30m, 2w). + +# prompt.confirm + +Delete %s trace file(s)? This cannot be undone. + +# output.noneFound + +No trace files matched the specified filters. + +# output.preview + +Found %s trace file(s) to delete: + +# output.cancelled + +Deletion cancelled. + +# output.deleted + +Deleted %s trace file(s). + +# output.tableHeader.agent + +Agent + +# output.tableHeader.sessionId + +Session ID + +# output.tableHeader.planId + +Plan ID + +# examples + +- Delete all traces for all agents and sessions (with confirmation prompt): + + <%= config.bin %> <%= command.id %> + +- Delete all traces for a specific agent: + + <%= config.bin %> <%= command.id %> --agent My_Agent + +- Delete traces from a specific session: + + <%= config.bin %> <%= command.id %> --session-id + +- Delete traces older than 7 days: + + <%= config.bin %> <%= command.id %> --older-than 7d + +- Delete traces older than 24 hours for a specific agent, no prompt: + + <%= config.bin %> <%= command.id %> --agent My_Agent --older-than 24h --no-prompt + +- Delete all traces without confirmation: + + <%= config.bin %> <%= command.id %> --no-prompt diff --git a/schemas/agent-trace-delete.json b/schemas/agent-trace-delete.json new file mode 100644 index 00000000..716fea24 --- /dev/null +++ b/schemas/agent-trace-delete.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentTraceDeleteResult", + "definitions": { + "AgentTraceDeleteResult": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "planId": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["agent", "sessionId", "planId", "path"], + "additionalProperties": false + } + } + } +} diff --git a/src/commands/agent/trace/delete.ts b/src/commands/agent/trace/delete.ts new file mode 100644 index 00000000..6a5363e1 --- /dev/null +++ b/src/commands/agent/trace/delete.ts @@ -0,0 +1,141 @@ +/* + * 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 { unlink } from 'node:fs/promises'; +import { Flags, SfCommand, toHelpSection } from '@salesforce/sf-plugins-core'; +import { Messages, SfError } from '@salesforce/core'; +import { listCachedPreviewSessions, listSessionTraces, type TraceFileInfo } from '@salesforce/agents'; +import yesNoOrCancel from '../../../yes-no-cancel.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.trace.delete'); + +const DURATION_RE = /^(\d+)(d|h|m|w|days?|hours?|minutes?|weeks?)$/i; +const UNIT_MS: Record = { + m: 60_000, + minute: 60_000, + minutes: 60_000, + h: 3_600_000, + hour: 3_600_000, + hours: 3_600_000, + d: 86_400_000, + day: 86_400_000, + days: 86_400_000, + w: 604_800_000, + week: 604_800_000, + weeks: 604_800_000, +}; + +export type AgentTraceDeleteResult = Array<{ + agent: string; + sessionId: string; + planId: string; + path: string; +}>; + +export default class AgentTraceDelete extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly requiresProject = true; + + public static readonly errorCodes = toHelpSection('ERROR CODES', { + 'Succeeded (0)': 'Traces deleted successfully (or no traces matched).', + }); + + public static readonly flags = { + agent: Flags.string({ + summary: messages.getMessage('flags.agent.summary'), + char: 'a', + }), + 'session-id': Flags.string({ + summary: messages.getMessage('flags.session-id.summary'), + }), + 'older-than': Flags.custom({ + summary: messages.getMessage('flags.older-than.summary'), + // eslint-disable-next-line @typescript-eslint/require-await + parse: async (raw): Promise => { + const match = DURATION_RE.exec(raw); + if (!match) { + throw new SfError(messages.getMessage('error.invalidOlderThan', [raw]), 'InvalidDuration'); + } + const ms = parseInt(match[1], 10) * UNIT_MS[match[2].toLowerCase()]; + return new Date(Date.now() - ms); + }, + })(), + 'no-prompt': Flags.boolean({ + summary: messages.getMessage('flags.no-prompt.summary'), + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(AgentTraceDelete); + + const agentFilter = flags.agent?.toLowerCase(); + const cachedAgents = await listCachedPreviewSessions(this.project!); + + const candidates: AgentTraceDeleteResult = []; + for (const { agentId, displayName, sessions } of cachedAgents) { + if (agentFilter && !displayName?.toLowerCase().includes(agentFilter)) continue; + + for (const { sessionId } of sessions) { + if (flags['session-id'] && sessionId !== flags['session-id']) continue; + + // eslint-disable-next-line no-await-in-loop + let traces: TraceFileInfo[] = await listSessionTraces(agentId, sessionId); + + if (flags['older-than']) { + traces = traces.filter((t) => t.mtime < flags['older-than']!); + } + + for (const t of traces) { + candidates.push({ agent: displayName ?? agentId, sessionId, planId: t.planId, path: t.path }); + } + } + } + + if (candidates.length === 0) { + this.log(messages.getMessage('output.noneFound')); + return []; + } + + if (!flags['no-prompt']) { + this.log(messages.getMessage('output.preview', [candidates.length])); + this.table({ + data: candidates.map((c) => ({ agent: c.agent, sessionId: c.sessionId, planId: c.planId })), + columns: [ + { key: 'agent', name: messages.getMessage('output.tableHeader.agent') }, + { key: 'sessionId', name: messages.getMessage('output.tableHeader.sessionId') }, + { key: 'planId', name: messages.getMessage('output.tableHeader.planId') }, + ], + }); + + const confirmed = await yesNoOrCancel({ + message: messages.getMessage('prompt.confirm', [candidates.length]), + default: false, + }); + + if (confirmed === 'cancel' || confirmed === false) { + this.log(messages.getMessage('output.cancelled')); + return []; + } + } + + await Promise.all(candidates.map((c) => unlink(c.path))); + this.log(messages.getMessage('output.deleted', [candidates.length])); + return candidates; + } +} diff --git a/test/commands/agent/trace/delete.test.ts b/test/commands/agent/trace/delete.test.ts new file mode 100644 index 00000000..d8371be5 --- /dev/null +++ b/test/commands/agent/trace/delete.test.ts @@ -0,0 +1,239 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */ + +import { join } from 'node:path'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; +import { TestContext } from '@salesforce/core/testSetup'; +import { SfProject } from '@salesforce/core'; + +const MOCK_PROJECT_DIR = join(process.cwd(), 'test', 'mock-projects', 'agent-generate-template'); + +// Dates well in the past so --older-than arithmetic is predictable without fake timers. +// RECENT_MTIME: ~23 days ago from 2026-04-30 — caught by 30d but not 7d +// OLD_MTIME: ~60 days ago from 2026-04-30 — caught by both 7d and 30d +const RECENT_MTIME = new Date('2026-04-07T17:00:00.000Z'); +const OLD_MTIME = new Date('2026-03-01T00:00:00.000Z'); + +const MOCK_TRACES_AGENT_A = [ + { planId: 'plan-1', path: '/sfdx/agents/AgentA/sessions/sess-1/traces/plan-1.json', size: 1000, mtime: RECENT_MTIME }, + { planId: 'plan-2', path: '/sfdx/agents/AgentA/sessions/sess-1/traces/plan-2.json', size: 2000, mtime: OLD_MTIME }, +]; +const MOCK_TRACES_AGENT_B = [ + { planId: 'plan-3', path: '/sfdx/agents/AgentB/sessions/sess-2/traces/plan-3.json', size: 3000, mtime: OLD_MTIME }, +]; + +const MOCK_CACHED_SESSIONS = [ + { + agentId: 'AgentA', + displayName: 'My_Agent_A', + sessions: [{ sessionId: 'sess-1', timestamp: RECENT_MTIME.toISOString() }], + }, + { + agentId: 'AgentB', + displayName: 'My_Agent_B', + sessions: [{ sessionId: 'sess-2', timestamp: OLD_MTIME.toISOString() }], + }, +]; + +describe('agent trace delete', () => { + const $$ = new TestContext(); + let unlinkStub: sinon.SinonStub; + let listCachedPreviewSessionsStub: sinon.SinonStub; + let listSessionTracesStub: sinon.SinonStub; + let yesNoOrCancelStub: sinon.SinonStub; + let AgentTraceDelete: any; + + beforeEach(async () => { + unlinkStub = $$.SANDBOX.stub().resolves(); + listCachedPreviewSessionsStub = $$.SANDBOX.stub().resolves(MOCK_CACHED_SESSIONS); + listSessionTracesStub = $$.SANDBOX.stub(); + listSessionTracesStub.withArgs('AgentA', 'sess-1').resolves(MOCK_TRACES_AGENT_A); + listSessionTracesStub.withArgs('AgentB', 'sess-2').resolves(MOCK_TRACES_AGENT_B); + yesNoOrCancelStub = $$.SANDBOX.stub().resolves(true); + + const mod = await esmock('../../../../src/commands/agent/trace/delete.js', { + 'node:fs/promises': { unlink: unlinkStub }, + '@salesforce/agents': { + listCachedPreviewSessions: listCachedPreviewSessionsStub, + listSessionTraces: listSessionTracesStub, + }, + '../../../../src/yes-no-cancel.js': { default: yesNoOrCancelStub }, + }); + + AgentTraceDelete = mod.default; + + $$.inProject(true); + const mockProject = { getPath: () => MOCK_PROJECT_DIR } as unknown as SfProject; + $$.SANDBOX.stub(SfProject, 'resolve').resolves(mockProject); + $$.SANDBOX.stub(SfProject, 'getInstance').returns(mockProject); + }); + + afterEach(() => { + $$.restore(); + }); + + describe('with no filters', () => { + it('deletes all traces across all agents when --no-prompt is set', async () => { + const result = await AgentTraceDelete.run(['--no-prompt']); + expect(result).to.have.length(3); + expect(unlinkStub.callCount).to.equal(3); + }); + + it('prompts for confirmation by default', async () => { + await AgentTraceDelete.run([]); + expect(yesNoOrCancelStub.calledOnce).to.be.true; + }); + + it('does not delete when user declines confirmation', async () => { + yesNoOrCancelStub.resolves(false); + const result = await AgentTraceDelete.run([]); + expect(result).to.deep.equal([]); + expect(unlinkStub.called).to.be.false; + }); + + it('does not delete when user cancels confirmation', async () => { + yesNoOrCancelStub.resolves('cancel'); + const result = await AgentTraceDelete.run([]); + expect(result).to.deep.equal([]); + expect(unlinkStub.called).to.be.false; + }); + + it('returns empty and does not prompt when no traces exist', async () => { + listSessionTracesStub.withArgs('AgentA', 'sess-1').resolves([]); + listSessionTracesStub.withArgs('AgentB', 'sess-2').resolves([]); + const result = await AgentTraceDelete.run([]); + expect(result).to.deep.equal([]); + expect(yesNoOrCancelStub.called).to.be.false; + expect(unlinkStub.called).to.be.false; + }); + }); + + describe('--no-prompt', () => { + it('skips the confirmation prompt', async () => { + await AgentTraceDelete.run(['--no-prompt']); + expect(yesNoOrCancelStub.called).to.be.false; + }); + }); + + describe('--agent filter', () => { + it('deletes only traces for the matching agent', async () => { + const result = await AgentTraceDelete.run(['--agent', 'My_Agent_A', '--no-prompt']); + expect(result).to.have.length(2); + expect(result.every((r: any) => r.agent === 'My_Agent_A')).to.be.true; + expect(unlinkStub.callCount).to.equal(2); + }); + + it('uses case-insensitive substring match', async () => { + const result = await AgentTraceDelete.run(['--agent', 'agent_a', '--no-prompt']); + expect(result).to.have.length(2); + }); + + it('returns empty when no agents match', async () => { + const result = await AgentTraceDelete.run(['--agent', 'NonExistent', '--no-prompt']); + expect(result).to.deep.equal([]); + expect(unlinkStub.called).to.be.false; + }); + }); + + describe('--session-id filter', () => { + it('deletes only traces for the specified session', async () => { + const result = await AgentTraceDelete.run(['--session-id', 'sess-2', '--no-prompt']); + expect(result).to.have.length(1); + expect(result[0].sessionId).to.equal('sess-2'); + }); + + it('returns empty when session ID does not match', async () => { + const result = await AgentTraceDelete.run(['--session-id', 'no-such-session', '--no-prompt']); + expect(result).to.deep.equal([]); + }); + }); + + describe('--older-than filter', () => { + it('deletes only traces older than the duration (days)', async () => { + const result = await AgentTraceDelete.run(['--older-than', '30d', '--no-prompt']); + const planIds = result.map((r: any) => r.planId); + expect(planIds).to.include('plan-2'); + expect(planIds).to.include('plan-3'); + expect(planIds).to.not.include('plan-1'); + }); + + it('deletes nothing when all traces are newer than the duration', async () => { + const futureMtime = new Date(Date.now() + 86_400_000); + listSessionTracesStub + .withArgs('AgentA', 'sess-1') + .resolves([ + { + planId: 'plan-1', + path: '/sfdx/agents/AgentA/sessions/sess-1/traces/plan-1.json', + size: 1000, + mtime: futureMtime, + }, + ]); + listSessionTracesStub.withArgs('AgentB', 'sess-2').resolves([]); + const result = await AgentTraceDelete.run(['--older-than', '1d', '--no-prompt']); + expect(result).to.deep.equal([]); + }); + + it('accepts hours unit', async () => { + const result = await AgentTraceDelete.run(['--older-than', '1h', '--no-prompt']); + expect(result).to.have.length(3); + }); + + it('accepts weeks unit', async () => { + const result = await AgentTraceDelete.run(['--older-than', '4w', '--no-prompt']); + const planIds = result.map((r: any) => r.planId); + expect(planIds).to.include('plan-2'); + expect(planIds).to.include('plan-3'); + expect(planIds).to.not.include('plan-1'); + }); + + it('rejects a value without a unit', async () => { + try { + await AgentTraceDelete.run(['--older-than', '7', '--no-prompt']); + expect.fail('Should have thrown'); + } catch (err: unknown) { + expect((err as Error).message).to.match(/invalid.*older-than|InvalidDuration/i); + } + }); + + it('rejects a non-numeric value', async () => { + try { + await AgentTraceDelete.run(['--older-than', 'lastweek', '--no-prompt']); + expect.fail('Should have thrown'); + } catch (err: unknown) { + expect((err as Error).message).to.match(/invalid.*older-than|InvalidDuration/i); + } + }); + }); + + describe('combined filters', () => { + it('applies --agent and --older-than together', async () => { + const result = await AgentTraceDelete.run(['--agent', 'My_Agent_A', '--older-than', '30d', '--no-prompt']); + expect(result).to.have.length(1); + expect(result[0].planId).to.equal('plan-2'); + }); + + it('applies --session-id and --agent together', async () => { + const result = await AgentTraceDelete.run(['--agent', 'My_Agent_A', '--session-id', 'sess-1', '--no-prompt']); + expect(result).to.have.length(2); + expect(result.every((r: any) => r.sessionId === 'sess-1')).to.be.true; + }); + }); +}); diff --git a/test/nuts/z5.agent.trace.delete.nut.ts b/test/nuts/z5.agent.trace.delete.nut.ts new file mode 100644 index 00000000..5d1a8de2 --- /dev/null +++ b/test/nuts/z5.agent.trace.delete.nut.ts @@ -0,0 +1,118 @@ +/* + * 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 { expect } from 'chai'; +import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; +import type { AgentPreviewStartResult } from '../../src/commands/agent/preview/start.js'; +import type { AgentPreviewSendResult } from '../../src/commands/agent/preview/send.js'; +import type { AgentPreviewEndResult } from '../../src/commands/agent/preview/end.js'; +import type { AgentTraceDeleteResult } from '../../src/commands/agent/trace/delete.js'; +import { getTestSession, getUsername } from './shared-setup.js'; + +describe('agent trace delete', function () { + this.timeout(30 * 60 * 1000); + + let session: TestSession; + let sessionId: string; + const bundleApiName = 'Willie_Resort_Manager'; + + before(async function () { + this.timeout(30 * 60 * 1000); + session = await getTestSession(); + + // Start a preview session so there are traces to delete + const targetOrg = getUsername(); + const startResult = execCmd( + `agent preview start --authoring-bundle ${bundleApiName} --simulate-actions --target-org ${targetOrg} --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + expect(startResult?.sessionId).to.be.a('string'); + sessionId = startResult!.sessionId; + + execCmd( + `agent preview send --session-id ${sessionId} --authoring-bundle ${bundleApiName} --utterance "What can you help me with?" --target-org ${targetOrg} --json`, + { ensureExitCode: 0 } + ); + + execCmd( + `agent preview end --session-id ${sessionId} --authoring-bundle ${bundleApiName} --target-org ${targetOrg} --json`, + { ensureExitCode: 0 } + ); + }); + + it('returns empty array when no traces match the filter', () => { + const result = execCmd( + 'agent trace delete --session-id no-such-session --no-prompt --json', + { ensureExitCode: 0, cwd: session.project.dir } + ).jsonOutput?.result; + expect(result).to.deep.equal([]); + }); + + it('deletes traces for a specific session and returns deleted entries', () => { + const result = execCmd(`agent trace delete --session-id ${sessionId} --no-prompt --json`, { + ensureExitCode: 0, + cwd: session.project.dir, + }).jsonOutput?.result; + expect(result).to.be.an('array').with.length.greaterThan(0); + expect(result!.every((r) => r.sessionId === sessionId)).to.be.true; + }); + + it('each deleted entry has required fields', () => { + // Create a fresh session to delete + const targetOrg = getUsername(); + const startResult = execCmd( + `agent preview start --authoring-bundle ${bundleApiName} --simulate-actions --target-org ${targetOrg} --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + const newSessionId = startResult!.sessionId; + + execCmd( + `agent preview send --session-id ${newSessionId} --authoring-bundle ${bundleApiName} --utterance "Hello" --target-org ${targetOrg} --json`, + { ensureExitCode: 0 } + ); + execCmd( + `agent preview end --session-id ${newSessionId} --authoring-bundle ${bundleApiName} --target-org ${targetOrg} --json`, + { ensureExitCode: 0 } + ); + + const result = execCmd( + `agent trace delete --session-id ${newSessionId} --no-prompt --json`, + { ensureExitCode: 0, cwd: session.project.dir } + ).jsonOutput?.result; + expect(result).to.be.an('array').with.length.greaterThan(0); + const entry = result![0]; + expect(entry).to.have.keys(['agent', 'sessionId', 'planId', 'path']); + expect(entry.sessionId).to.equal(newSessionId); + }); + + it('deletes traces older than a given duration with --older-than', () => { + // All traces just created are only seconds old, so --older-than 1d should delete nothing + const result = execCmd('agent trace delete --older-than 1d --no-prompt --json', { + ensureExitCode: 0, + cwd: session.project.dir, + }).jsonOutput?.result; + // Traces just created should not match --older-than 1d + expect(result).to.be.an('array'); + }); + + it('deletes all remaining traces with --no-prompt (cleanup)', () => { + const result = execCmd(`agent trace delete --agent ${bundleApiName} --no-prompt --json`, { + ensureExitCode: 0, + cwd: session.project.dir, + }).jsonOutput?.result; + expect(result).to.be.an('array'); + }); +});