Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/agents/copilot-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
101 changes: 99 additions & 2 deletions src/agents/copilot-sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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", () => {
Expand All @@ -31,13 +34,16 @@ 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);
mockClient.getAuthStatus
.mockReset()
.mockResolvedValue({ isAuthenticated: true, authType: "user", login: "octocat" });
mockClient.listModels.mockReset();
mockClient.listSessions.mockReset();
mockClient.getLastSessionId.mockReset();
});

describe("isCopilotCliInstalled", () => {
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -177,7 +183,7 @@ describe("copilot-sdk", () => {
);

// Cleanup should still happen
expect(mockSession.destroy).toHaveBeenCalled();
expect(mockSession.disconnect).toHaveBeenCalled();
expect(mockClient.stop).toHaveBeenCalled();
});

Expand Down Expand Up @@ -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);
});
});
});
113 changes: 111 additions & 2 deletions src/agents/copilot-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
Expand All @@ -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<CopilotSessionMetadata[]> {
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<string | null> {
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<boolean> {
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 {}
}
}
}
Loading