diff --git a/packages/mcp/src/functions/delete-session.ts b/packages/mcp/src/functions/delete-session.ts new file mode 100644 index 0000000..9fa4603 --- /dev/null +++ b/packages/mcp/src/functions/delete-session.ts @@ -0,0 +1,129 @@ +import type { JulesClient } from '@google/jules-sdk'; +import type { DeleteSessionOptions, DeleteSessionResult } from './types.js'; + +/** + * Deletes sessions by ID or filter. + * + * @param client - The Jules client instance + * @param options - Deletion options (sessionId, filter, force) + * @returns Deletion result + */ +export async function deleteSession( + client: JulesClient, + options: DeleteSessionOptions, +): Promise { + const { sessionId, filter, force } = options; + + if (sessionId) { + try { + await client.session(sessionId).delete(); + return { + success: true, + deletedCount: 1, + sessionIds: [sessionId], + message: `Session ${sessionId} deleted successfully.`, + }; + } catch (error: any) { + return { + success: false, + deletedCount: 0, + sessionIds: [], + message: `Failed to delete session ${sessionId}: ${error.message}`, + }; + } + } + + if (filter) { + const sessionsToDelete: string[] = []; + + // Iterate through all sessions to find matches. + // By default client.sessions() excludes archived sessions, which is usually desired. + for await (const session of client.sessions()) { + let match = false; + switch (filter) { + case 'completed': + match = session.state === 'completed'; + break; + case 'failed': + match = session.state === 'failed'; + break; + case 'running': + match = [ + 'queued', + 'planning', + 'inProgress', + 'awaitingPlanApproval', + 'awaitingUserFeedback', + 'paused', + ].includes(session.state); + break; + case 'queued': + match = session.state === 'queued'; + break; + case 'planning': + match = session.state === 'planning'; + break; + case 'inProgress': + match = session.state === 'inProgress'; + break; + case 'awaitingPlanApproval': + match = session.state === 'awaitingPlanApproval'; + break; + } + + if (match) { + sessionsToDelete.push(session.id); + } + } + + if (sessionsToDelete.length === 0) { + return { + success: true, + deletedCount: 0, + sessionIds: [], + message: `No sessions found matching filter: ${filter}`, + }; + } + + if (!force) { + return { + success: false, + deletedCount: 0, + sessionIds: sessionsToDelete, + message: `Found ${sessionsToDelete.length} sessions matching filter "${filter}". Use 'force: true' to delete them.`, + requiresForce: true, + }; + } + + const deletedIds: string[] = []; + const errors: string[] = []; + + // Delete in parallel with some concurrency or sequentially? + // Let's go with sequential for safety or use a simple loop. + for (const id of sessionsToDelete) { + try { + await client.session(id).delete(); + deletedIds.push(id); + } catch (error: any) { + errors.push(`Failed to delete session ${id}: ${error.message}`); + } + } + + return { + success: errors.length === 0, + deletedCount: deletedIds.length, + sessionIds: deletedIds, + message: + errors.length === 0 + ? `Successfully deleted ${deletedIds.length} sessions matching filter "${filter}".` + : `Deleted ${deletedIds.length} sessions with ${errors.length} errors. Errors: ${errors.join('; ')}`, + }; + } + + return { + success: false, + deletedCount: 0, + sessionIds: [], + message: 'Either sessionId or filter must be provided.', + }; +} diff --git a/packages/mcp/src/functions/index.ts b/packages/mcp/src/functions/index.ts index 37f705a..e2694ff 100644 --- a/packages/mcp/src/functions/index.ts +++ b/packages/mcp/src/functions/index.ts @@ -6,6 +6,7 @@ // Export all functions export { getSessionState } from './session-state.js'; export { listSessions } from './list-sessions.js'; +export { deleteSession } from './delete-session.js'; export { createSession } from './create-session.js'; export { interact } from './interact.js'; export { select } from './select.js'; @@ -27,6 +28,10 @@ export type { // List Sessions ListSessionsOptions, ListSessionsResult, + // Delete Session + DeleteSessionFilter, + DeleteSessionOptions, + DeleteSessionResult, // Create Session CreateSessionOptions, CreateSessionResult, diff --git a/packages/mcp/src/functions/types.ts b/packages/mcp/src/functions/types.ts index 9e378fb..1c6019d 100644 --- a/packages/mcp/src/functions/types.ts +++ b/packages/mcp/src/functions/types.ts @@ -327,3 +327,33 @@ export interface WorkInProgressResult { summary: WorkInProgressSummary; formatted: string; } + +// ============================================================================ +// Delete Session +// ============================================================================ + +export type DeleteSessionFilter = + | 'completed' + | 'failed' + | 'running' + | 'queued' + | 'planning' + | 'inProgress' + | 'awaitingPlanApproval'; + +export interface DeleteSessionOptions { + sessionId?: string; + filter?: DeleteSessionFilter; + /** + * Required when deleting multiple sessions via filter. + */ + force?: boolean; +} + +export interface DeleteSessionResult { + success: boolean; + deletedCount: number; + sessionIds: string[]; + message: string; + requiresForce?: boolean; +} diff --git a/packages/mcp/src/tools/delete-session.tool.ts b/packages/mcp/src/tools/delete-session.tool.ts new file mode 100644 index 0000000..b914e5a --- /dev/null +++ b/packages/mcp/src/tools/delete-session.tool.ts @@ -0,0 +1,42 @@ +import type { JulesClient } from '@google/jules-sdk'; +import { deleteSession } from '../functions/delete-session.js'; +import { defineTool, toMcpResponse } from './utils.js'; + +export default defineTool({ + name: 'delete_session', + description: 'Deletes one or more Jules sessions.', + inputSchema: { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'The unique ID of the session to delete.', + }, + filter: { + type: 'string', + description: 'A filter to delete multiple sessions.', + enum: [ + 'completed', + 'failed', + 'running', + 'queued', + 'planning', + 'inProgress', + 'awaitingPlanApproval', + ], + }, + force: { + type: 'boolean', + description: 'Required when deleting multiple sessions via filter.', + }, + }, + }, + handler: async (client: JulesClient, args: any) => { + const result = await deleteSession(client, { + sessionId: args?.sessionId, + filter: args?.filter, + force: args?.force, + }); + return toMcpResponse(result); + }, +}); diff --git a/packages/mcp/tests/functions/delete-session.test.ts b/packages/mcp/tests/functions/delete-session.test.ts new file mode 100644 index 0000000..77f319b --- /dev/null +++ b/packages/mcp/tests/functions/delete-session.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { deleteSession } from '../../src/functions/delete-session.js'; +import { createMockClient } from './helpers.js'; + +describe('deleteSession', () => { + let mockClient: ReturnType; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('deletes a single session by ID', async () => { + const mockSession = { + delete: vi.fn().mockResolvedValue(undefined), + }; + vi.spyOn(mockClient, 'session').mockReturnValue(mockSession as any); + + const result = await deleteSession(mockClient, { sessionId: 'session-123' }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(1); + expect(result.sessionIds).toEqual(['session-123']); + expect(mockSession.delete).toHaveBeenCalled(); + }); + + it('fails when single session deletion fails', async () => { + const mockSession = { + delete: vi.fn().mockRejectedValue(new Error('API Error')), + }; + vi.spyOn(mockClient, 'session').mockReturnValue(mockSession as any); + + const result = await deleteSession(mockClient, { sessionId: 'session-123' }); + + expect(result.success).toBe(false); + expect(result.deletedCount).toBe(0); + expect(result.message).toContain('Failed to delete session session-123'); + }); + + it('deletes multiple sessions with filter and force', async () => { + const sessions = [ + { id: 's1', state: 'completed' }, + { id: 's2', state: 'failed' }, + { id: 's3', state: 'completed' }, + ]; + + vi.spyOn(mockClient, 'sessions').mockReturnValue({ + [Symbol.asyncIterator]: async function* () { + for (const s of sessions) yield s; + }, + } as any); + + const mockDelete = vi.fn().mockResolvedValue(undefined); + vi.spyOn(mockClient, 'session').mockReturnValue({ + delete: mockDelete, + } as any); + + const result = await deleteSession(mockClient, { + filter: 'completed', + force: true, + }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(2); + expect(result.sessionIds).toEqual(['s1', 's3']); + expect(mockDelete).toHaveBeenCalledTimes(2); + }); + + it('requires force for multiple sessions', async () => { + const sessions = [{ id: 's1', state: 'completed' }]; + + vi.spyOn(mockClient, 'sessions').mockReturnValue({ + [Symbol.asyncIterator]: async function* () { + for (const s of sessions) yield s; + }, + } as any); + + const result = await deleteSession(mockClient, { filter: 'completed' }); + + expect(result.success).toBe(false); + expect(result.requiresForce).toBe(true); + expect(result.sessionIds).toEqual(['s1']); + }); + + it('handles running filter correctly', async () => { + const sessions = [ + { id: 's1', state: 'inProgress' }, + { id: 's2', state: 'completed' }, + { id: 's3', state: 'queued' }, + ]; + + vi.spyOn(mockClient, 'sessions').mockReturnValue({ + [Symbol.asyncIterator]: async function* () { + for (const s of sessions) yield s; + }, + } as any); + + const mockDelete = vi.fn().mockResolvedValue(undefined); + vi.spyOn(mockClient, 'session').mockReturnValue({ + delete: mockDelete, + } as any); + + const result = await deleteSession(mockClient, { + filter: 'running', + force: true, + }); + + expect(result.deletedCount).toBe(2); + expect(result.sessionIds).toEqual(['s1', 's3']); + }); + + it('returns success when no sessions match filter', async () => { + vi.spyOn(mockClient, 'sessions').mockReturnValue({ + [Symbol.asyncIterator]: async function* () { + // empty + }, + } as any); + + const result = await deleteSession(mockClient, { filter: 'completed' }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(0); + expect(result.message).toContain('No sessions found matching filter'); + }); +});