From bf88cf25446f91c07790ea5134088cccd7845112 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 30 Apr 2026 11:18:03 -0600 Subject: [PATCH 1/5] feat: add agent preview trace delete command --- command-snapshot.json | 8 + messages/agent.preview.trace.delete.md | 91 ++++++ schemas/agent-preview-trace-delete.json | 28 ++ src/commands/agent/preview/trace/delete.ts | 151 ++++++++++ .../agent/preview/trace/delete.test.ts | 265 ++++++++++++++++++ 5 files changed, 543 insertions(+) create mode 100644 messages/agent.preview.trace.delete.md create mode 100644 schemas/agent-preview-trace-delete.json create mode 100644 src/commands/agent/preview/trace/delete.ts create mode 100644 test/commands/agent/preview/trace/delete.test.ts diff --git a/command-snapshot.json b/command-snapshot.json index 4c01e472..d24abed1 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -153,6 +153,14 @@ ], "plugin": "@salesforce/plugin-agent" }, + { + "alias": [], + "command": "agent:preview:trace:delete", + "flagAliases": [], + "flagChars": ["n"], + "flags": ["api-name", "authoring-bundle", "flags-dir", "json", "no-prompt", "older-than", "session-id"], + "plugin": "@salesforce/plugin-agent" + }, { "alias": [], "command": "agent:preview:trace:list", diff --git a/messages/agent.preview.trace.delete.md b/messages/agent.preview.trace.delete.md new file mode 100644 index 00000000..17776b61 --- /dev/null +++ b/messages/agent.preview.trace.delete.md @@ -0,0 +1,91 @@ +# 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 (--api-name or --authoring-bundle), by session (--session-id), or by age (--older-than). + +# flags.session-id.summary + +Only delete traces from this session ID. + +# flags.api-name.summary + +Only delete traces for this published agent API name (substring match). + +# flags.authoring-bundle.summary + +Only delete traces for this authoring bundle API name (substring match). + +# 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 published agent: + + <%= config.bin %> <%= command.id %> --api-name My_Published_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 %> --authoring-bundle My_Local_Agent --older-than 24h --no-prompt + +- Delete all traces for an authoring bundle without confirmation: + + <%= config.bin %> <%= command.id %> --authoring-bundle My_Local_Agent --no-prompt diff --git a/schemas/agent-preview-trace-delete.json b/schemas/agent-preview-trace-delete.json new file mode 100644 index 00000000..04572127 --- /dev/null +++ b/schemas/agent-preview-trace-delete.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AgentPreviewTraceDeleteResult", + "definitions": { + "AgentPreviewTraceDeleteResult": { + "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/preview/trace/delete.ts b/src/commands/agent/preview/trace/delete.ts new file mode 100644 index 00000000..18399120 --- /dev/null +++ b/src/commands/agent/preview/trace/delete.ts @@ -0,0 +1,151 @@ +/* + * 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.preview.trace.delete'); + +// Parses "" where unit is d/days, h/hours, m/minutes, w/weeks. +// Returns the cutoff Date (now minus the duration), or undefined on invalid input. +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 AgentPreviewTraceDeleteResult = Array<{ + agent: string; + sessionId: string; + planId: string; + path: string; +}>; + +export default class AgentPreviewTraceDelete 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 = { + 'session-id': Flags.string({ + summary: messages.getMessage('flags.session-id.summary'), + }), + 'api-name': Flags.string({ + summary: messages.getMessage('flags.api-name.summary'), + char: 'n', + }), + 'authoring-bundle': Flags.string({ + summary: messages.getMessage('flags.authoring-bundle.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'), + default: false, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(AgentPreviewTraceDelete); + + const agentNameFilter = (flags['authoring-bundle'] ?? flags['api-name'])?.toLowerCase(); + const sessionIdFilter = flags['session-id']; + const olderThan: Date | undefined = flags['older-than']; + + const cachedAgents = await listCachedPreviewSessions(this.project!); + + // Collect all matching traces + const candidates: AgentPreviewTraceDeleteResult = []; + for (const { agentId, displayName, sessions } of cachedAgents) { + if (agentNameFilter && !displayName?.toLowerCase().includes(agentNameFilter)) continue; + + for (const { sessionId } of sessions) { + if (sessionIdFilter && sessionId !== sessionIdFilter) continue; + + // eslint-disable-next-line no-await-in-loop + let traces: TraceFileInfo[] = await listSessionTraces(agentId, sessionId); + + if (olderThan) { + traces = traces.filter((t) => t.mtime < olderThan); + } + + 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/preview/trace/delete.test.ts b/test/commands/agent/preview/trace/delete.test.ts new file mode 100644 index 00000000..5f6887ba --- /dev/null +++ b/test/commands/agent/preview/trace/delete.test.ts @@ -0,0 +1,265 @@ +/* + * 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 preview trace delete', () => { + const $$ = new TestContext(); + let unlinkStub: sinon.SinonStub; + let listCachedPreviewSessionsStub: sinon.SinonStub; + let listSessionTracesStub: sinon.SinonStub; + let yesNoOrCancelStub: sinon.SinonStub; + let AgentPreviewTraceDelete: 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/preview/trace/delete.js', { + 'node:fs/promises': { unlink: unlinkStub }, + '@salesforce/agents': { + listCachedPreviewSessions: listCachedPreviewSessionsStub, + listSessionTraces: listSessionTracesStub, + }, + '../../../../../src/yes-no-cancel.js': { default: yesNoOrCancelStub }, + }); + + AgentPreviewTraceDelete = 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 AgentPreviewTraceDelete.run(['--no-prompt']); + expect(result).to.have.length(3); + expect(unlinkStub.callCount).to.equal(3); + }); + + it('prompts for confirmation by default', async () => { + await AgentPreviewTraceDelete.run([]); + expect(yesNoOrCancelStub.calledOnce).to.be.true; + }); + + it('does not delete when user declines confirmation', async () => { + yesNoOrCancelStub.resolves(false); + const result = await AgentPreviewTraceDelete.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 AgentPreviewTraceDelete.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 AgentPreviewTraceDelete.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 AgentPreviewTraceDelete.run(['--no-prompt']); + expect(yesNoOrCancelStub.called).to.be.false; + }); + }); + + describe('--api-name filter', () => { + it('deletes only traces for the matching agent', async () => { + const result = await AgentPreviewTraceDelete.run(['--api-name', '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 AgentPreviewTraceDelete.run(['--api-name', 'agent_a', '--no-prompt']); + expect(result).to.have.length(2); + }); + + it('returns empty when no agents match', async () => { + const result = await AgentPreviewTraceDelete.run(['--api-name', 'NonExistent', '--no-prompt']); + expect(result).to.deep.equal([]); + expect(unlinkStub.called).to.be.false; + }); + }); + + describe('--authoring-bundle filter', () => { + it('deletes only traces for the matching bundle', async () => { + const result = await AgentPreviewTraceDelete.run(['--authoring-bundle', 'My_Agent_B', '--no-prompt']); + expect(result).to.have.length(1); + expect(result[0].agent).to.equal('My_Agent_B'); + }); + }); + + describe('--session-id filter', () => { + it('deletes only traces for the specified session', async () => { + const result = await AgentPreviewTraceDelete.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 AgentPreviewTraceDelete.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 () => { + // OLD_MTIME is ~60 days ago — caught by 30d. RECENT_MTIME is ~23 days ago — not caught. + const result = await AgentPreviewTraceDelete.run(['--older-than', '30d', '--no-prompt']); + const planIds = result.map((r: any) => r.planId); + expect(planIds).to.include('plan-2'); // OLD, AgentA + expect(planIds).to.include('plan-3'); // OLD, AgentB + expect(planIds).to.not.include('plan-1'); // RECENT, not deleted + }); + + it('deletes nothing when all traces are newer than the duration', async () => { + // Override traces with mtimes in the future so nothing is "older than 1 day" + 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 AgentPreviewTraceDelete.run(['--older-than', '1d', '--no-prompt']); + expect(result).to.deep.equal([]); + }); + + it('accepts hours unit', async () => { + // OLD_MTIME is weeks old, caught by 1h. RECENT_MTIME is ~23 days old, also caught. + // Use a very large hours value to catch everything. + const result = await AgentPreviewTraceDelete.run(['--older-than', '1h', '--no-prompt']); + expect(result).to.have.length(3); + }); + + it('accepts weeks unit', async () => { + // OLD_MTIME is ~8-9 weeks ago — caught by 4w. RECENT_MTIME is ~3 weeks ago — not caught. + const result = await AgentPreviewTraceDelete.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 AgentPreviewTraceDelete.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 AgentPreviewTraceDelete.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 filters together', async () => { + // Only plan-2 (AgentA + OLD) — plan-1 is recent, AgentB is excluded by name filter + const result = await AgentPreviewTraceDelete.run([ + '--api-name', + '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 filters together', async () => { + const result = await AgentPreviewTraceDelete.run([ + '--api-name', + '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; + }); + }); +}); From 05aa65dc5d5654e4b2b9f10e0018c2c48d1914e8 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 30 Apr 2026 11:36:04 -0600 Subject: [PATCH 2/5] chore: cleanup --- messages/agent.preview.trace.delete.md | 4 ++-- src/commands/agent/preview/trace/delete.ts | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/messages/agent.preview.trace.delete.md b/messages/agent.preview.trace.delete.md index 17776b61..9df1f930 100644 --- a/messages/agent.preview.trace.delete.md +++ b/messages/agent.preview.trace.delete.md @@ -14,11 +14,11 @@ Only delete traces from this session ID. # flags.api-name.summary -Only delete traces for this published agent API name (substring match). +Only delete traces for this published agent API name. # flags.authoring-bundle.summary -Only delete traces for this authoring bundle API name (substring match). +Only delete traces for this authoring bundle API name. # flags.older-than.summary diff --git a/src/commands/agent/preview/trace/delete.ts b/src/commands/agent/preview/trace/delete.ts index 18399120..967551c3 100644 --- a/src/commands/agent/preview/trace/delete.ts +++ b/src/commands/agent/preview/trace/delete.ts @@ -83,7 +83,6 @@ export default class AgentPreviewTraceDelete extends SfCommand t.mtime < olderThan); + if (flags['older-than']) { + traces = traces.filter((t) => t.mtime < flags['older-than']!); } for (const t of traces) { From dae86e0172125499a9e01295a6f8e57b95ef645d Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 30 Apr 2026 11:40:41 -0600 Subject: [PATCH 3/5] feat: rename to agent trace delete, replace --api-name/--authoring-bundle with --agent --- command-snapshot.json | 16 ++-- ....trace.delete.md => agent.trace.delete.md} | 24 +++--- ...ce-delete.json => agent-trace-delete.json} | 4 +- .../agent/{preview => }/trace/delete.ts | 33 +++---- .../agent/{preview => }/trace/delete.test.ts | 86 +++++++------------ 5 files changed, 63 insertions(+), 100 deletions(-) rename messages/{agent.preview.trace.delete.md => agent.trace.delete.md} (69%) rename schemas/{agent-preview-trace-delete.json => agent-trace-delete.json} (85%) rename src/commands/agent/{preview => }/trace/delete.ts (81%) rename test/commands/agent/{preview => }/trace/delete.test.ts (69%) diff --git a/command-snapshot.json b/command-snapshot.json index d24abed1..3c8eb216 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -153,14 +153,6 @@ ], "plugin": "@salesforce/plugin-agent" }, - { - "alias": [], - "command": "agent:preview:trace:delete", - "flagAliases": [], - "flagChars": ["n"], - "flags": ["api-name", "authoring-bundle", "flags-dir", "json", "no-prompt", "older-than", "session-id"], - "plugin": "@salesforce/plugin-agent" - }, { "alias": [], "command": "agent:preview:trace:list", @@ -257,6 +249,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:validate:authoring-bundle", diff --git a/messages/agent.preview.trace.delete.md b/messages/agent.trace.delete.md similarity index 69% rename from messages/agent.preview.trace.delete.md rename to messages/agent.trace.delete.md index 9df1f930..32f23c6d 100644 --- a/messages/agent.preview.trace.delete.md +++ b/messages/agent.trace.delete.md @@ -6,19 +6,15 @@ Delete agent preview trace files. 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 (--api-name or --authoring-bundle), by session (--session-id), or by age (--older-than). +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.session-id.summary - -Only delete traces from this session ID. +# flags.agent.summary -# flags.api-name.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. -Only delete traces for this published agent API name. - -# flags.authoring-bundle.summary +# flags.session-id.summary -Only delete traces for this authoring bundle API name. +Only delete traces from this session ID. # flags.older-than.summary @@ -70,9 +66,9 @@ Plan ID <%= config.bin %> <%= command.id %> -- Delete all traces for a specific published agent: +- Delete all traces for a specific agent: - <%= config.bin %> <%= command.id %> --api-name My_Published_Agent + <%= config.bin %> <%= command.id %> --agent My_Agent - Delete traces from a specific session: @@ -84,8 +80,8 @@ Plan ID - Delete traces older than 24 hours for a specific agent, no prompt: - <%= config.bin %> <%= command.id %> --authoring-bundle My_Local_Agent --older-than 24h --no-prompt + <%= config.bin %> <%= command.id %> --agent My_Agent --older-than 24h --no-prompt -- Delete all traces for an authoring bundle without confirmation: +- Delete all traces without confirmation: - <%= config.bin %> <%= command.id %> --authoring-bundle My_Local_Agent --no-prompt + <%= config.bin %> <%= command.id %> --no-prompt diff --git a/schemas/agent-preview-trace-delete.json b/schemas/agent-trace-delete.json similarity index 85% rename from schemas/agent-preview-trace-delete.json rename to schemas/agent-trace-delete.json index 04572127..716fea24 100644 --- a/schemas/agent-preview-trace-delete.json +++ b/schemas/agent-trace-delete.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/AgentPreviewTraceDeleteResult", + "$ref": "#/definitions/AgentTraceDeleteResult", "definitions": { - "AgentPreviewTraceDeleteResult": { + "AgentTraceDeleteResult": { "type": "array", "items": { "type": "object", diff --git a/src/commands/agent/preview/trace/delete.ts b/src/commands/agent/trace/delete.ts similarity index 81% rename from src/commands/agent/preview/trace/delete.ts rename to src/commands/agent/trace/delete.ts index 967551c3..6a5363e1 100644 --- a/src/commands/agent/preview/trace/delete.ts +++ b/src/commands/agent/trace/delete.ts @@ -18,13 +18,11 @@ 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'; +import yesNoOrCancel from '../../../yes-no-cancel.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.trace.delete'); +const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.trace.delete'); -// Parses "" where unit is d/days, h/hours, m/minutes, w/weeks. -// Returns the cutoff Date (now minus the duration), or undefined on invalid input. const DURATION_RE = /^(\d+)(d|h|m|w|days?|hours?|minutes?|weeks?)$/i; const UNIT_MS: Record = { m: 60_000, @@ -41,14 +39,14 @@ const UNIT_MS: Record = { weeks: 604_800_000, }; -export type AgentPreviewTraceDeleteResult = Array<{ +export type AgentTraceDeleteResult = Array<{ agent: string; sessionId: string; planId: string; path: string; }>; -export default class AgentPreviewTraceDelete extends SfCommand { +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'); @@ -59,16 +57,13 @@ export default class AgentPreviewTraceDelete extends SfCommand({ summary: messages.getMessage('flags.older-than.summary'), // eslint-disable-next-line @typescript-eslint/require-await @@ -86,17 +81,15 @@ export default class AgentPreviewTraceDelete extends SfCommand { - const { flags } = await this.parse(AgentPreviewTraceDelete); - - const agentNameFilter = (flags['authoring-bundle'] ?? flags['api-name'])?.toLowerCase(); + public async run(): Promise { + const { flags } = await this.parse(AgentTraceDelete); + const agentFilter = flags.agent?.toLowerCase(); const cachedAgents = await listCachedPreviewSessions(this.project!); - // Collect all matching traces - const candidates: AgentPreviewTraceDeleteResult = []; + const candidates: AgentTraceDeleteResult = []; for (const { agentId, displayName, sessions } of cachedAgents) { - if (agentNameFilter && !displayName?.toLowerCase().includes(agentNameFilter)) continue; + if (agentFilter && !displayName?.toLowerCase().includes(agentFilter)) continue; for (const { sessionId } of sessions) { if (flags['session-id'] && sessionId !== flags['session-id']) continue; diff --git a/test/commands/agent/preview/trace/delete.test.ts b/test/commands/agent/trace/delete.test.ts similarity index 69% rename from test/commands/agent/preview/trace/delete.test.ts rename to test/commands/agent/trace/delete.test.ts index 5f6887ba..d8371be5 100644 --- a/test/commands/agent/preview/trace/delete.test.ts +++ b/test/commands/agent/trace/delete.test.ts @@ -52,13 +52,13 @@ const MOCK_CACHED_SESSIONS = [ }, ]; -describe('agent preview trace delete', () => { +describe('agent trace delete', () => { const $$ = new TestContext(); let unlinkStub: sinon.SinonStub; let listCachedPreviewSessionsStub: sinon.SinonStub; let listSessionTracesStub: sinon.SinonStub; let yesNoOrCancelStub: sinon.SinonStub; - let AgentPreviewTraceDelete: any; + let AgentTraceDelete: any; beforeEach(async () => { unlinkStub = $$.SANDBOX.stub().resolves(); @@ -68,16 +68,16 @@ describe('agent preview trace delete', () => { listSessionTracesStub.withArgs('AgentB', 'sess-2').resolves(MOCK_TRACES_AGENT_B); yesNoOrCancelStub = $$.SANDBOX.stub().resolves(true); - const mod = await esmock('../../../../../src/commands/agent/preview/trace/delete.js', { + 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 }, + '../../../../src/yes-no-cancel.js': { default: yesNoOrCancelStub }, }); - AgentPreviewTraceDelete = mod.default; + AgentTraceDelete = mod.default; $$.inProject(true); const mockProject = { getPath: () => MOCK_PROJECT_DIR } as unknown as SfProject; @@ -91,26 +91,26 @@ describe('agent preview trace delete', () => { describe('with no filters', () => { it('deletes all traces across all agents when --no-prompt is set', async () => { - const result = await AgentPreviewTraceDelete.run(['--no-prompt']); + 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 AgentPreviewTraceDelete.run([]); + await AgentTraceDelete.run([]); expect(yesNoOrCancelStub.calledOnce).to.be.true; }); it('does not delete when user declines confirmation', async () => { yesNoOrCancelStub.resolves(false); - const result = await AgentPreviewTraceDelete.run([]); + 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 AgentPreviewTraceDelete.run([]); + const result = await AgentTraceDelete.run([]); expect(result).to.deep.equal([]); expect(unlinkStub.called).to.be.false; }); @@ -118,7 +118,7 @@ describe('agent preview trace delete', () => { 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 AgentPreviewTraceDelete.run([]); + const result = await AgentTraceDelete.run([]); expect(result).to.deep.equal([]); expect(yesNoOrCancelStub.called).to.be.false; expect(unlinkStub.called).to.be.false; @@ -127,64 +127,54 @@ describe('agent preview trace delete', () => { describe('--no-prompt', () => { it('skips the confirmation prompt', async () => { - await AgentPreviewTraceDelete.run(['--no-prompt']); + await AgentTraceDelete.run(['--no-prompt']); expect(yesNoOrCancelStub.called).to.be.false; }); }); - describe('--api-name filter', () => { + describe('--agent filter', () => { it('deletes only traces for the matching agent', async () => { - const result = await AgentPreviewTraceDelete.run(['--api-name', 'My_Agent_A', '--no-prompt']); + 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 AgentPreviewTraceDelete.run(['--api-name', 'agent_a', '--no-prompt']); + 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 AgentPreviewTraceDelete.run(['--api-name', 'NonExistent', '--no-prompt']); + const result = await AgentTraceDelete.run(['--agent', 'NonExistent', '--no-prompt']); expect(result).to.deep.equal([]); expect(unlinkStub.called).to.be.false; }); }); - describe('--authoring-bundle filter', () => { - it('deletes only traces for the matching bundle', async () => { - const result = await AgentPreviewTraceDelete.run(['--authoring-bundle', 'My_Agent_B', '--no-prompt']); - expect(result).to.have.length(1); - expect(result[0].agent).to.equal('My_Agent_B'); - }); - }); - describe('--session-id filter', () => { it('deletes only traces for the specified session', async () => { - const result = await AgentPreviewTraceDelete.run(['--session-id', 'sess-2', '--no-prompt']); + 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 AgentPreviewTraceDelete.run(['--session-id', 'no-such-session', '--no-prompt']); + 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 () => { - // OLD_MTIME is ~60 days ago — caught by 30d. RECENT_MTIME is ~23 days ago — not caught. - const result = await AgentPreviewTraceDelete.run(['--older-than', '30d', '--no-prompt']); + const result = await AgentTraceDelete.run(['--older-than', '30d', '--no-prompt']); const planIds = result.map((r: any) => r.planId); - expect(planIds).to.include('plan-2'); // OLD, AgentA - expect(planIds).to.include('plan-3'); // OLD, AgentB - expect(planIds).to.not.include('plan-1'); // RECENT, not deleted + 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 () => { - // Override traces with mtimes in the future so nothing is "older than 1 day" const futureMtime = new Date(Date.now() + 86_400_000); listSessionTracesStub .withArgs('AgentA', 'sess-1') @@ -197,20 +187,17 @@ describe('agent preview trace delete', () => { }, ]); listSessionTracesStub.withArgs('AgentB', 'sess-2').resolves([]); - const result = await AgentPreviewTraceDelete.run(['--older-than', '1d', '--no-prompt']); + const result = await AgentTraceDelete.run(['--older-than', '1d', '--no-prompt']); expect(result).to.deep.equal([]); }); it('accepts hours unit', async () => { - // OLD_MTIME is weeks old, caught by 1h. RECENT_MTIME is ~23 days old, also caught. - // Use a very large hours value to catch everything. - const result = await AgentPreviewTraceDelete.run(['--older-than', '1h', '--no-prompt']); + const result = await AgentTraceDelete.run(['--older-than', '1h', '--no-prompt']); expect(result).to.have.length(3); }); it('accepts weeks unit', async () => { - // OLD_MTIME is ~8-9 weeks ago — caught by 4w. RECENT_MTIME is ~3 weeks ago — not caught. - const result = await AgentPreviewTraceDelete.run(['--older-than', '4w', '--no-prompt']); + 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'); @@ -219,7 +206,7 @@ describe('agent preview trace delete', () => { it('rejects a value without a unit', async () => { try { - await AgentPreviewTraceDelete.run(['--older-than', '7', '--no-prompt']); + 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); @@ -228,7 +215,7 @@ describe('agent preview trace delete', () => { it('rejects a non-numeric value', async () => { try { - await AgentPreviewTraceDelete.run(['--older-than', 'lastweek', '--no-prompt']); + 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); @@ -237,27 +224,14 @@ describe('agent preview trace delete', () => { }); describe('combined filters', () => { - it('applies agent and older-than filters together', async () => { - // Only plan-2 (AgentA + OLD) — plan-1 is recent, AgentB is excluded by name filter - const result = await AgentPreviewTraceDelete.run([ - '--api-name', - 'My_Agent_A', - '--older-than', - '30d', - '--no-prompt', - ]); + 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 filters together', async () => { - const result = await AgentPreviewTraceDelete.run([ - '--api-name', - 'My_Agent_A', - '--session-id', - 'sess-1', - '--no-prompt', - ]); + 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; }); From 64eb2dd4222bbf6bbd769073892a173dbe45eeb3 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 30 Apr 2026 15:13:12 -0600 Subject: [PATCH 4/5] feat: add NUTs for agent trace delete --- test/nuts/z5.agent.trace.delete.nut.ts | 118 +++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 test/nuts/z5.agent.trace.delete.nut.ts 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'); + }); +}); From 2a5a62f623bbafa7eda4778ef5b33afaa34432de Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 30 Apr 2026 15:19:39 -0600 Subject: [PATCH 5/5] fix: regenerate command snapshot after rebase --- command-snapshot.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/command-snapshot.json b/command-snapshot.json index 17c0968e..966b1302 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -247,8 +247,10 @@ "flagAliases": [], "flagChars": ["a"], "flags": ["agent", "flags-dir", "json", "no-prompt", "older-than", "session-id"], + "plugin": "@salesforce/plugin-agent" }, { + "alias": [], "command": "agent:trace:list", "flagAliases": [], "flagChars": ["a"],