From 45182ed2d88845500a462a9aebc2968d1577d4dc Mon Sep 17 00:00:00 2001 From: Tag Date: Wed, 25 Mar 2026 01:35:23 -0400 Subject: [PATCH] feat(agents): add Copilot SDK session resume and lifecycle management --- src/agents/copilot-runner.ts | 5 +- src/agents/copilot-sdk.test.ts | 101 ++++++++++++++++++++++++++++- src/agents/copilot-sdk.ts | 113 ++++++++++++++++++++++++++++++++- 3 files changed, 214 insertions(+), 5 deletions(-) diff --git a/src/agents/copilot-runner.ts b/src/agents/copilot-runner.ts index 252708ccd43c0..394ba5294c6da 100644 --- a/src/agents/copilot-runner.ts +++ b/src/agents/copilot-runner.ts @@ -112,7 +112,10 @@ export async function runCopilotCliAgent(params: { }); try { - log.info(`copilot-cli exec: model=${modelId} promptChars=${params.prompt.length}`); + const isResume = !!params.cliSessionId; + log.info( + `copilot-cli exec: model=${modelId} promptChars=${params.prompt.length} session=${isResume ? "resume:" + params.cliSessionId : "new"}`, + ); const result = await runCopilotAgent({ prompt: params.prompt, diff --git a/src/agents/copilot-sdk.test.ts b/src/agents/copilot-sdk.test.ts index 593a680949cc8..1ea531993bec1 100644 --- a/src/agents/copilot-sdk.test.ts +++ b/src/agents/copilot-sdk.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const mockSession = { sendAndWait: vi.fn(), destroy: vi.fn(), + disconnect: vi.fn(), sessionId: "mock-session-id", }; const mockClient = { @@ -14,6 +15,8 @@ const mockClient = { .fn() .mockResolvedValue({ isAuthenticated: true, authType: "user", login: "octocat" }), listModels: vi.fn(), + listSessions: vi.fn(), + getLastSessionId: vi.fn(), }; vi.mock("@github/copilot-sdk", () => { @@ -31,6 +34,7 @@ describe("copilot-sdk", () => { afterEach(() => { mockSession.sendAndWait.mockReset(); mockSession.destroy.mockReset().mockResolvedValue(undefined); + mockSession.disconnect.mockReset().mockResolvedValue(undefined); mockClient.stop.mockReset().mockResolvedValue(undefined); mockClient.createSession.mockReset().mockResolvedValue(mockSession); mockClient.resumeSession.mockReset().mockResolvedValue(mockSession); @@ -38,6 +42,8 @@ describe("copilot-sdk", () => { .mockReset() .mockResolvedValue({ isAuthenticated: true, authType: "user", login: "octocat" }); mockClient.listModels.mockReset(); + mockClient.listSessions.mockReset(); + mockClient.getLastSessionId.mockReset(); }); describe("isCopilotCliInstalled", () => { @@ -132,7 +138,7 @@ describe("copilot-sdk", () => { expect(mockClient.createSession).toHaveBeenCalledTimes(1); expect(mockClient.resumeSession).not.toHaveBeenCalled(); expect(mockSession.sendAndWait).toHaveBeenCalledWith({ prompt: "Say hello" }, 5_000); - expect(mockSession.destroy).toHaveBeenCalled(); + expect(mockSession.disconnect).toHaveBeenCalled(); expect(mockClient.stop).toHaveBeenCalled(); }); @@ -177,7 +183,7 @@ describe("copilot-sdk", () => { ); // Cleanup should still happen - expect(mockSession.destroy).toHaveBeenCalled(); + expect(mockSession.disconnect).toHaveBeenCalled(); expect(mockClient.stop).toHaveBeenCalled(); }); @@ -241,4 +247,95 @@ describe("copilot-sdk", () => { expect(client).toBeDefined(); }); }); + + describe("listCopilotSessions", () => { + it("returns sessions when authenticated", async () => { + const mockSessions = [ + { + sessionId: "s1", + startTime: new Date("2026-03-25T00:00:00Z"), + modifiedTime: new Date("2026-03-25T01:00:00Z"), + summary: "Fix bug", + context: { cwd: "/tmp", repository: "owner/repo", branch: "main" }, + }, + ]; + mockClient.listSessions.mockResolvedValueOnce(mockSessions); + + const { listCopilotSessions } = await import("./copilot-sdk.js"); + const sessions = await listCopilotSessions(); + expect(sessions).toEqual(mockSessions); + expect(mockClient.listSessions).toHaveBeenCalledWith(undefined); + expect(mockClient.stop).toHaveBeenCalled(); + }); + + it("passes filter to client", async () => { + mockClient.listSessions.mockResolvedValueOnce([]); + + const { listCopilotSessions } = await import("./copilot-sdk.js"); + await listCopilotSessions({ repository: "owner/repo", branch: "main" }); + expect(mockClient.listSessions).toHaveBeenCalledWith({ + repository: "owner/repo", + branch: "main", + }); + }); + + it("returns empty array on failure", async () => { + mockClient.listSessions.mockRejectedValueOnce(new Error("fail")); + + const { listCopilotSessions } = await import("./copilot-sdk.js"); + const sessions = await listCopilotSessions(); + expect(sessions).toEqual([]); + }); + }); + + describe("getLastCopilotSessionId", () => { + it("returns session id when available", async () => { + mockClient.getLastSessionId.mockResolvedValueOnce("last-session-123"); + + const { getLastCopilotSessionId } = await import("./copilot-sdk.js"); + const id = await getLastCopilotSessionId(); + expect(id).toBe("last-session-123"); + expect(mockClient.stop).toHaveBeenCalled(); + }); + + it("returns null when no sessions exist", async () => { + mockClient.getLastSessionId.mockResolvedValueOnce(undefined); + + const { getLastCopilotSessionId } = await import("./copilot-sdk.js"); + const id = await getLastCopilotSessionId(); + expect(id).toBeNull(); + }); + + it("returns null on error", async () => { + mockClient.getLastSessionId.mockRejectedValueOnce(new Error("fail")); + + const { getLastCopilotSessionId } = await import("./copilot-sdk.js"); + const id = await getLastCopilotSessionId(); + expect(id).toBeNull(); + }); + }); + + describe("destroyCopilotSession", () => { + it("resumes and destroys session", async () => { + mockSession.destroy.mockResolvedValueOnce(undefined); + + const { destroyCopilotSession } = await import("./copilot-sdk.js"); + const result = await destroyCopilotSession("session-to-delete"); + expect(result).toBe(true); + expect(mockClient.resumeSession).toHaveBeenCalledWith( + "session-to-delete", + expect.objectContaining({ onPermissionRequest: expect.any(Function) }), + ); + expect(mockSession.destroy).toHaveBeenCalled(); + expect(mockClient.stop).toHaveBeenCalled(); + }); + + it("returns false on failure", async () => { + mockClient.resumeSession.mockRejectedValueOnce(new Error("not found")); + + const { destroyCopilotSession } = await import("./copilot-sdk.js"); + const result = await destroyCopilotSession("bad-id"); + expect(result).toBe(false); + }); + }); }); diff --git a/src/agents/copilot-sdk.ts b/src/agents/copilot-sdk.ts index 067dffbb9c0b9..0045edd1904cf 100644 --- a/src/agents/copilot-sdk.ts +++ b/src/agents/copilot-sdk.ts @@ -180,9 +180,9 @@ export async function runCopilotAgent( } finally { if (session) { try { - await session.destroy(); + await session.disconnect(); } catch (err) { - log.warn("failed to destroy copilot session", { + log.warn("failed to disconnect copilot session", { error: err instanceof Error ? err.message : String(err), }); } @@ -196,3 +196,112 @@ export async function runCopilotAgent( } } } + +/** + * Session filter for listing Copilot sessions. + */ +export type CopilotSessionFilter = { + cwd?: string; + repository?: string; + branch?: string; +}; + +/** + * Metadata for a persisted Copilot session. + */ +export type CopilotSessionMetadata = { + sessionId: string; + startTime: Date; + modifiedTime: Date; + summary?: string; + isRemote: boolean; + context?: { + cwd?: string; + gitRoot?: string; + repository?: string; + branch?: string; + }; +}; + +/** + * List persisted Copilot sessions, optionally filtered. + */ +export async function listCopilotSessions( + filter?: CopilotSessionFilter, +): Promise { + let client: CopilotClient | undefined; + try { + client = await createCopilotClient(); + await ensureAuthenticated(client); + const sessions = await client.listSessions(filter); + return sessions; + } catch (error) { + log.warn("failed to list copilot sessions", { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } finally { + if (client) { + try { + await client.stop(); + } catch {} + } + } +} + +/** + * Get the session ID of the most recently used Copilot session. + */ +export async function getLastCopilotSessionId(): Promise { + let client: CopilotClient | undefined; + try { + client = await createCopilotClient(); + await ensureAuthenticated(client); + const sessionId = await client.getLastSessionId(); + return sessionId ?? null; + } catch (error) { + log.warn("failed to get last copilot session id", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } finally { + if (client) { + try { + await client.stop(); + } catch {} + } + } +} + +/** + * Permanently destroy a Copilot session by ID. + * Use this for explicit cleanup when a session is no longer needed. + */ +export async function destroyCopilotSession(sessionId: string): Promise { + let client: CopilotClient | undefined; + try { + client = await createCopilotClient(); + await ensureAuthenticated(client); + const session = await client.resumeSession(sessionId, { + onPermissionRequest: async () => ({ + kind: "denied-interactively-by-user" as const, + feedback: "Tool use is not permitted in this session.", + }), + }); + await session.destroy(); + log.info("destroyed copilot session", { sessionId }); + return true; + } catch (error) { + log.warn("failed to destroy copilot session", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } finally { + if (client) { + try { + await client.stop(); + } catch {} + } + } +}