From 818af2b42cdf456682778b0756f08e12237dd657 Mon Sep 17 00:00:00 2001 From: Tag Date: Wed, 25 Mar 2026 01:35:26 -0400 Subject: [PATCH] feat(agents): enable Copilot SDK infinite sessions with auto-compaction --- pnpm-lock.yaml | 86 ++++++++++++++++++++++++++ src/agents/copilot-runner.test.ts | 41 ++++++++++++ src/agents/copilot-runner.ts | 15 +++++ src/agents/copilot-sdk.test.ts | 57 +++++++++++++++++ src/agents/copilot-sdk.ts | 18 +++++- src/agents/pi-embedded-runner/types.ts | 2 + 6 files changed, 218 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a393c0340016a..604dbb07c249a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@clack/prompts': specifier: ^1.1.0 version: 1.1.0 + '@github/copilot-sdk': + specifier: 0.1.32 + version: 0.1.32 '@homebridge/ciao': specifier: ^1.3.5 version: 1.3.5 @@ -1350,6 +1353,50 @@ packages: '@noble/hashes': optional: true + '@github/copilot-darwin-arm64@1.0.11': + resolution: {integrity: sha512-wdKimjtbsVeXqMqQSnGpGBPFEYHljxXNuWeH8EIJTNRgFpAsimcivsFgql3Twq4YOp0AxfsH36icG4IEen30mA==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@github/copilot-darwin-x64@1.0.11': + resolution: {integrity: sha512-VeuPv8rzBVGBB8uDwMEhcHBpldoKaq26yZ5YQm+G9Ka5QIF+1DMah8ZNRMVsTeNKkb1ji9G8vcuCsaPbnG3fKg==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@github/copilot-linux-arm64@1.0.11': + resolution: {integrity: sha512-/d8p6RlFYKj1Va2hekFIcYNMHWagcEkaxgcllUNXSyQLnmEtXUkaWtz62VKGWE+n/UMkEwCB6vI2xEwPTlUNBQ==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@github/copilot-linux-x64@1.0.11': + resolution: {integrity: sha512-UujTRO3xkPFC1CybchBbCnaTEAG6JrH0etIst07JvfekMWgvRxbiCHQPpDPSzBCPiBcGu0gba0/IT+vUCORuIw==} + cpu: [x64] + os: [linux] + hasBin: true + + '@github/copilot-sdk@0.1.32': + resolution: {integrity: sha512-mPWM0fw1Gqc/SW8nl45K8abrFH+92fO7y6tRtRl5imjS5hGapLf/dkX5WDrgPtlsflD0c41lFXVUri5NVJwtoA==} + engines: {node: '>=20.0.0'} + + '@github/copilot-win32-arm64@1.0.11': + resolution: {integrity: sha512-EOW8HUM+EmnHEZEa+iUMl4pP1+2eZUk2XCbynYiMehwX9sidc4BxEHp2RuxADSzFPTieQEWzgjQmHWrtet8pQg==} + cpu: [arm64] + os: [win32] + hasBin: true + + '@github/copilot-win32-x64@1.0.11': + resolution: {integrity: sha512-fKGkSNamzs3h9AbmswNvPYJBORCb2Y8CbusijU3C7fT3ohvqnHJwKo5iHhJXLOKZNOpFZgq9YKha410u9sIs6Q==} + cpu: [x64] + os: [win32] + hasBin: true + + '@github/copilot@1.0.11': + resolution: {integrity: sha512-cptVopko/tNKEXyBP174yBjHQBEwg6CqaKN2S0M3J+5LEB8u31bLL75ioOPd+5vubqBrA0liyTdcHeZ8UTRbmg==} + hasBin: true + '@google/genai@1.42.0': resolution: {integrity: sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw==} engines: {node: '>=20.0.0'} @@ -6697,6 +6744,10 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + vscode-jsonrpc@8.2.1: + resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==} + engines: {node: '>=14.0.0'} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -7850,6 +7901,39 @@ snapshots: optionalDependencies: '@noble/hashes': 2.0.1 + '@github/copilot-darwin-arm64@1.0.11': + optional: true + + '@github/copilot-darwin-x64@1.0.11': + optional: true + + '@github/copilot-linux-arm64@1.0.11': + optional: true + + '@github/copilot-linux-x64@1.0.11': + optional: true + + '@github/copilot-sdk@0.1.32': + dependencies: + '@github/copilot': 1.0.11 + vscode-jsonrpc: 8.2.1 + zod: 4.3.6 + + '@github/copilot-win32-arm64@1.0.11': + optional: true + + '@github/copilot-win32-x64@1.0.11': + optional: true + + '@github/copilot@1.0.11': + optionalDependencies: + '@github/copilot-darwin-arm64': 1.0.11 + '@github/copilot-darwin-x64': 1.0.11 + '@github/copilot-linux-arm64': 1.0.11 + '@github/copilot-linux-x64': 1.0.11 + '@github/copilot-win32-arm64': 1.0.11 + '@github/copilot-win32-x64': 1.0.11 + '@google/genai@1.42.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -13579,6 +13663,8 @@ snapshots: void-elements@3.1.0: {} + vscode-jsonrpc@8.2.1: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/src/agents/copilot-runner.test.ts b/src/agents/copilot-runner.test.ts index c4cc7291f34ee..4abaa172cc9a1 100644 --- a/src/agents/copilot-runner.test.ts +++ b/src/agents/copilot-runner.test.ts @@ -174,4 +174,45 @@ describe("runCopilotCliAgent", () => { expect(sdkArgs.model).toBeUndefined(); expect(result.meta?.agentMeta?.model).toBe("default"); }); + + it("passes infiniteSessions config enabled by default", async () => { + checkCopilotAvailableMock.mockReturnValue({ available: true }); + runCopilotAgentMock.mockResolvedValueOnce({ + text: "ok", + sessionId: "sid-inf", + }); + + await runCopilotCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + timeoutMs: 5_000, + runId: "run-7", + }); + + const sdkArgs = runCopilotAgentMock.mock.calls[0]?.[0]; + expect(sdkArgs.infiniteSessions).toBeDefined(); + expect(sdkArgs.infiniteSessions.enabled).toBe(true); + }); + + it("includes workspacePath in result meta when returned by SDK", async () => { + checkCopilotAvailableMock.mockReturnValue({ available: true }); + runCopilotAgentMock.mockResolvedValueOnce({ + text: "ok", + sessionId: "sid-ws", + workspacePath: "/tmp/copilot-workspace/session-ws", + }); + + const result = await runCopilotCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + timeoutMs: 5_000, + runId: "run-8", + }); + + expect(result.meta?.workspacePath).toBe("/tmp/copilot-workspace/session-ws"); + }); }); diff --git a/src/agents/copilot-runner.ts b/src/agents/copilot-runner.ts index 252708ccd43c0..9f0cc3f4c0c87 100644 --- a/src/agents/copilot-runner.ts +++ b/src/agents/copilot-runner.ts @@ -114,6 +114,19 @@ export async function runCopilotCliAgent(params: { try { log.info(`copilot-cli exec: model=${modelId} promptChars=${params.prompt.length}`); + // Enable infinite sessions by default for copilot-cli runs (they can be long-running). + // Thresholds are configurable via cli backend config overrides. + const infiniteSessionsConfig = backendConfig?.config as Record | undefined; + const infiniteSessions = { + enabled: true, + ...(typeof infiniteSessionsConfig?.backgroundCompactionThreshold === "number" + ? { backgroundCompactionThreshold: infiniteSessionsConfig.backgroundCompactionThreshold } + : {}), + ...(typeof infiniteSessionsConfig?.bufferExhaustionThreshold === "number" + ? { bufferExhaustionThreshold: infiniteSessionsConfig.bufferExhaustionThreshold } + : {}), + }; + const result = await runCopilotAgent({ prompt: params.prompt, model: modelId === "default" ? undefined : modelId, @@ -121,6 +134,7 @@ export async function runCopilotCliAgent(params: { systemPrompt, timeoutMs: params.timeoutMs, sessionId: params.cliSessionId, + infiniteSessions, }); const text = result.text?.trim(); @@ -135,6 +149,7 @@ export async function runCopilotCliAgent(params: { provider: "copilot-cli", model: modelId, }, + workspacePath: result.workspacePath, }, }; } catch (err) { diff --git a/src/agents/copilot-sdk.test.ts b/src/agents/copilot-sdk.test.ts index 593a680949cc8..23f44b8de02cb 100644 --- a/src/agents/copilot-sdk.test.ts +++ b/src/agents/copilot-sdk.test.ts @@ -5,6 +5,7 @@ const mockSession = { sendAndWait: vi.fn(), destroy: vi.fn(), sessionId: "mock-session-id", + workspacePath: undefined as string | undefined, }; const mockClient = { stop: vi.fn(), @@ -199,6 +200,62 @@ describe("copilot-sdk", () => { content: "You are a helpful assistant.", }); }); + + it("passes infiniteSessions config to session", async () => { + mockSession.sendAndWait.mockResolvedValueOnce({ + data: { content: "ok" }, + }); + + const { runCopilotAgent } = await import("./copilot-sdk.js"); + await runCopilotAgent({ + prompt: "hi", + timeoutMs: 5_000, + infiniteSessions: { + enabled: true, + backgroundCompactionThreshold: 0.75, + bufferExhaustionThreshold: 0.9, + }, + }); + + const sessionConfig = mockClient.createSession.mock.calls[0]?.[0]; + expect(sessionConfig.infiniteSessions).toEqual({ + enabled: true, + backgroundCompactionThreshold: 0.75, + bufferExhaustionThreshold: 0.9, + }); + }); + + it("does not set infiniteSessions when not provided", async () => { + mockSession.sendAndWait.mockResolvedValueOnce({ + data: { content: "ok" }, + }); + + const { runCopilotAgent } = await import("./copilot-sdk.js"); + await runCopilotAgent({ + prompt: "hi", + timeoutMs: 5_000, + }); + + const sessionConfig = mockClient.createSession.mock.calls[0]?.[0]; + expect(sessionConfig.infiniteSessions).toBeUndefined(); + }); + + it("returns workspacePath when available", async () => { + mockSession.workspacePath = "/tmp/copilot-workspace/session-123"; + mockSession.sendAndWait.mockResolvedValueOnce({ + data: { content: "ok" }, + }); + + const { runCopilotAgent } = await import("./copilot-sdk.js"); + const result = await runCopilotAgent({ + prompt: "hi", + timeoutMs: 5_000, + infiniteSessions: { enabled: true }, + }); + + expect(result.workspacePath).toBe("/tmp/copilot-workspace/session-123"); + mockSession.workspacePath = undefined; + }); }); describe("listCopilotModels", () => { diff --git a/src/agents/copilot-sdk.ts b/src/agents/copilot-sdk.ts index 067dffbb9c0b9..c69d9664d7f67 100644 --- a/src/agents/copilot-sdk.ts +++ b/src/agents/copilot-sdk.ts @@ -2,6 +2,7 @@ import type { CopilotClient, CopilotClientOptions, CopilotSession, + InfiniteSessionConfig, ModelInfo, SessionConfig, } from "@github/copilot-sdk"; @@ -116,11 +117,17 @@ export type CopilotAgentRunOptions = { systemPrompt?: string; timeoutMs?: number; sessionId?: string; + infiniteSessions?: { + enabled?: boolean; + backgroundCompactionThreshold?: number; + bufferExhaustionThreshold?: number; + }; }; export type CopilotAgentRunResult = { text: string; sessionId: string; + workspacePath?: string; }; /** @@ -151,6 +158,10 @@ export async function runCopilotAgent( }), }; + if (options.infiniteSessions) { + sessionConfig.infiniteSessions = options.infiniteSessions as InfiniteSessionConfig; + } + if (options.systemPrompt) { sessionConfig.systemMessage = { mode: "append", @@ -169,6 +180,11 @@ export async function runCopilotAgent( const text = response?.data?.content ?? ""; const sessionId = session.sessionId; + const workspacePath = session.workspacePath; + + if (workspacePath) { + log.info("infinite session workspace", { workspacePath, sessionId }); + } log.info(`copilot agent run completed`, { sessionId, @@ -176,7 +192,7 @@ export async function runCopilotAgent( responseLength: text.length, }); - return { text, sessionId }; + return { text, sessionId, workspacePath }; } finally { if (session) { try { diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 722abbf2a9ae7..c7418171b2c4f 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -52,6 +52,8 @@ export type EmbeddedPiRunMeta = { name: string; arguments: string; }>; + /** Workspace path for Copilot SDK infinite session state persistence. */ + workspacePath?: string; }; export type EmbeddedPiRunResult = {