diff --git a/ts/packages/aiclient/src/copilotModels.ts b/ts/packages/aiclient/src/copilotModels.ts index 543b35a42d..afce31b3ba 100644 --- a/ts/packages/aiclient/src/copilotModels.ts +++ b/ts/packages/aiclient/src/copilotModels.ts @@ -182,9 +182,14 @@ export interface CopilotClientOptions { cliUrl?: string | undefined; } +function configuredCopilotPath(): string | undefined { + return ( + process.env.TYPEAGENT_COPILOT_CLI_PATH ?? process.env.COPILOT_CLI_PATH + ); +} + function findCopilotPath(): string | undefined { - const configuredPath = - process.env.TYPEAGENT_COPILOT_CLI_PATH ?? process.env.COPILOT_CLI_PATH; + const configuredPath = configuredCopilotPath(); if (configuredPath) { debug(`Using configured copilot CLI: ${configuredPath}`); return configuredPath; @@ -204,7 +209,7 @@ function findCopilotPath(): string | undefined { export function createCopilotRuntimeConnection( cliUrl: string | undefined, - cliPath: string | undefined, + cliPath: string | undefined = configuredCopilotPath(), ) { return cliUrl ? RuntimeConnection.forUri(cliUrl) diff --git a/ts/packages/aiclient/src/index.ts b/ts/packages/aiclient/src/index.ts index 74ff9352e8..0c8fea8ff5 100644 --- a/ts/packages/aiclient/src/index.ts +++ b/ts/packages/aiclient/src/index.ts @@ -29,6 +29,7 @@ export * from "./requestConstraints.js"; export { getCopilotClient, getCopilotCliPath, + createCopilotRuntimeConnection, stopCopilotClient, createCopilotModelEndpointProvider, warmupCopilotClient, diff --git a/ts/packages/aiclient/test/copilotTransport.spec.ts b/ts/packages/aiclient/test/copilotTransport.spec.ts index bfb209abb1..cee6b92d32 100644 --- a/ts/packages/aiclient/test/copilotTransport.spec.ts +++ b/ts/packages/aiclient/test/copilotTransport.spec.ts @@ -170,6 +170,27 @@ const RESPONSES_OK = { }; describe("createCopilotRuntimeConnection", () => { + const envKeys = ["TYPEAGENT_COPILOT_CLI_PATH", "COPILOT_CLI_PATH"] as const; + let savedEnv: (string | undefined)[]; + + beforeEach(() => { + savedEnv = envKeys.map((key) => process.env[key]); + for (const key of envKeys) { + delete process.env[key]; + } + }); + + afterEach(() => { + envKeys.forEach((key, index) => { + const value = savedEnv[index]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }); + }); + test("uses a configured runtime URI instead of an executable path", () => { expect( createCopilotRuntimeConnection( @@ -184,6 +205,8 @@ describe("createCopilotRuntimeConnection", () => { }); test("preserves a concrete external executable path", () => { + process.env.TYPEAGENT_COPILOT_CLI_PATH = "C:\\managed\\copilot.exe"; + process.env.COPILOT_CLI_PATH = "C:\\fallback\\copilot.exe"; expect( createCopilotRuntimeConnection(undefined, "C:\\tools\\copilot.exe"), ).toEqual({ @@ -194,6 +217,37 @@ describe("createCopilotRuntimeConnection", () => { }); }); + test("prefers the TypeAgent runtime over the generic CLI setting", () => { + process.env.TYPEAGENT_COPILOT_CLI_PATH = "C:\\managed\\copilot.exe"; + process.env.COPILOT_CLI_PATH = "C:\\fallback\\copilot.exe"; + expect( + createCopilotRuntimeConnection(undefined, undefined), + ).toMatchObject({ + kind: "stdio", + path: "C:\\managed\\copilot.exe", + }); + }); + + test("honors the generic CLI setting when no TypeAgent runtime is configured", () => { + process.env.COPILOT_CLI_PATH = "C:\\fallback\\copilot.exe"; + expect( + createCopilotRuntimeConnection(undefined, undefined), + ).toMatchObject({ + kind: "stdio", + path: "C:\\fallback\\copilot.exe", + }); + }); + + test("keeps a remote URI ahead of configured local runtimes", () => { + process.env.TYPEAGENT_COPILOT_CLI_PATH = "C:\\managed\\copilot.exe"; + expect( + createCopilotRuntimeConnection("localhost:4321", undefined), + ).toMatchObject({ + kind: "uri", + url: "localhost:4321", + }); + }); + test("omits the path to select the SDK-managed runtime", () => { expect(createCopilotRuntimeConnection(undefined, undefined)).toEqual({ kind: "stdio", diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts index b01cdc1a49..296e28a554 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts @@ -15,7 +15,6 @@ import { import { ReasoningAction } from "../context/dispatcher/schema/reasoningActionSchema.js"; import { CopilotClient, - RuntimeConnection, defineTool, type AssistantMessageEvent, type CopilotSession, @@ -25,6 +24,7 @@ import { type MessageOptions, type SessionConfig, } from "@github/copilot-sdk"; +import { createCopilotRuntimeConnection } from "@typeagent/aiclient"; import registerDebug from "debug"; import os from "node:os"; import path from "node:path"; @@ -389,9 +389,10 @@ async function createCopilotClient( const creditBudget = getCopilotCreditBudget(); const client = new CopilotClient({ - connection: RuntimeConnection.forStdio( + connection: createCopilotRuntimeConnection( + undefined, creditBudget && process.env.TYPEAGENT_GHCP_EVAL_CLI - ? { path: process.env.TYPEAGENT_GHCP_EVAL_CLI } + ? process.env.TYPEAGENT_GHCP_EVAL_CLI : undefined, ), ...(creditBudget ? { requestHandler: creditBudget } : {}), diff --git a/ts/packages/dispatcher/dispatcher/test/copilotRuntimeConnection.spec.ts b/ts/packages/dispatcher/dispatcher/test/copilotRuntimeConnection.spec.ts new file mode 100644 index 0000000000..77aa112ef0 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/copilotRuntimeConnection.spec.ts @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { CommandHandlerContext } from "../src/context/commandHandlerContext.js"; +import { prewarmCopilotReasoning } from "../src/reasoning/copilot.js"; + +describe("Copilot reasoning runtime selection", () => { + const envKeys = [ + "TYPEAGENT_COPILOT_CLI_PATH", + "COPILOT_CLI_PATH", + "TYPEAGENT_GHCP_EVAL_CLI", + "TYPEAGENT_COPILOT_CREDIT_LEDGER", + ] as const; + let savedEnv: (string | undefined)[]; + let tempDir: string; + + beforeEach(() => { + savedEnv = envKeys.map((key) => process.env[key]); + for (const key of envKeys) { + delete process.env[key]; + } + tempDir = mkdtempSync(path.join(os.tmpdir(), "ta-reasoning-runtime-")); + jest.spyOn(os, "tmpdir").mockReturnValue(tempDir); + jest.spyOn(CopilotClient.prototype, "start").mockRejectedValue( + new Error("Test does not launch a runtime"), + ); + jest.spyOn(RuntimeConnection, "forStdio"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + envKeys.forEach((key, index) => { + const value = savedEnv[index]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test.each([ + { + name: "prefers the TypeAgent managed executable", + managed: "C:\\managed\\copilot.exe", + generic: "C:\\generic\\copilot.exe", + expected: "C:\\managed\\copilot.exe", + }, + { + name: "uses the generic configured executable", + generic: "C:\\generic\\copilot.exe", + expected: "C:\\generic\\copilot.exe", + }, + { + name: "retains SDK discovery without configuration", + expected: undefined, + }, + { + name: "ignores the evaluation executable without a credit budget", + managed: "C:\\managed\\copilot.exe", + evaluation: "C:\\evaluation\\copilot.exe", + expected: "C:\\managed\\copilot.exe", + }, + { + name: "prefers the evaluation executable with a credit budget", + managed: "C:\\managed\\copilot.exe", + generic: "C:\\generic\\copilot.exe", + evaluation: "C:\\evaluation\\copilot.exe", + creditBudget: true, + expected: "C:\\evaluation\\copilot.exe", + }, + { + name: "uses the managed executable for credit budgets without an evaluation override", + managed: "C:\\managed\\copilot.exe", + creditBudget: true, + expected: "C:\\managed\\copilot.exe", + }, + ])( + "$name", + async ({ managed, generic, evaluation, creditBudget, expected }) => { + if (managed) process.env.TYPEAGENT_COPILOT_CLI_PATH = managed; + if (generic) process.env.COPILOT_CLI_PATH = generic; + if (evaluation) process.env.TYPEAGENT_GHCP_EVAL_CLI = evaluation; + if (creditBudget) { + process.env.TYPEAGENT_COPILOT_CREDIT_LEDGER = path.join( + tempDir, + "ledger.json", + ); + } + const context = { + session: { + getConfig: () => ({ execution: { reasoning: "copilot" } }), + }, + } as unknown as CommandHandlerContext; + + prewarmCopilotReasoning(context); + await new Promise((resolve) => setImmediate(resolve)); + + expect(CopilotClient.prototype.start).toHaveBeenCalledTimes(1); + expect(RuntimeConnection.forStdio).toHaveBeenCalledWith( + expected ? { path: expected } : {}, + ); + }, + ); +}); diff --git a/ts/tools/installers/wix/README.md b/ts/tools/installers/wix/README.md index 171af5e2e6..1d50c6cf19 100644 --- a/ts/tools/installers/wix/README.md +++ b/ts/tools/installers/wix/README.md @@ -284,6 +284,19 @@ The managed Copilot CLI cache uses `TYPEAGENT_COPILOT_RUNTIME_ROOT`. `TYPEAGENT_RUNTIME_ROOT` is reserved for the bundled agent-server's application assets and must not be overridden by installer lifecycle actions. +The runtime helper ignores `TYPEAGENT_RUNTIME_ROOT` when locating the managed +cache. Without a dedicated override, it uses `%LOCALAPPDATA%\TypeAgent\runtimes` +on Windows and `$XDG_DATA_HOME/TypeAgent/runtimes` (or +`~/.local/share/TypeAgent/runtimes`) elsewhere. `--runtime-root` overrides these +defaults and `TYPEAGENT_COPILOT_RUNTIME_ROOT`. + +Prerequisite setup starts the selected runtime and checks its SDK status even +with `--runtime-only`; it does not require sign-in or model access for this +check. Startup failures return a nonzero setup exit code. The MSI logs these as +incomplete setup warnings without rolling back the installation. Reasoning and model clients use +`TYPEAGENT_COPILOT_CLI_PATH` before `COPILOT_CLI_PATH`; an explicit client path, +including the credit-budget evaluation override, takes precedence. + **Output:** ``` diff --git a/ts/tools/scripts/copilotRuntime.mjs b/ts/tools/scripts/copilotRuntime.mjs index 68bb61d380..b60cc0273c 100644 --- a/ts/tools/scripts/copilotRuntime.mjs +++ b/ts/tools/scripts/copilotRuntime.mjs @@ -95,9 +95,6 @@ export function defaultRuntimeRoot(env = process.env) { if (env.TYPEAGENT_COPILOT_RUNTIME_ROOT) { return path.resolve(env.TYPEAGENT_COPILOT_RUNTIME_ROOT); } - if (env.TYPEAGENT_RUNTIME_ROOT) { - return path.resolve(env.TYPEAGENT_RUNTIME_ROOT); - } if (process.platform === "win32" && env.LOCALAPPDATA) { return path.join(env.LOCALAPPDATA, "TypeAgent", "runtimes"); } @@ -514,7 +511,7 @@ export function resolveCopilotRuntime( ); } -async function inspectCopilot(executable) { +async function inspectCopilot(executable, { runtimeOnly = false } = {}) { let sdk; try { sdk = await import("@github/copilot-sdk"); @@ -541,6 +538,9 @@ async function inspectCopilot(executable) { try { await client.start(); const runtime = await client.getStatus(); + if (runtimeOnly) { + return { runtime }; + } const auth = await client.getAuthStatus(); const models = auth.isAuthenticated ? await client.listModels() : []; return { @@ -578,6 +578,8 @@ async function setupCopilot(manifest, options) { console.log(`GitHub Copilot runtime: ${executable}`); if (!options.login) { + await inspectCopilot(executable, { runtimeOnly: true }); + console.log("GitHub Copilot runtime startup verified."); return 0; } @@ -651,7 +653,7 @@ function printHelp() { " --artifact-dir ", " --manifest ", " --runtime-root ", - " --runtime-only", + " --runtime-only Verify runtime startup without signing in", " --non-interactive", " --device-code", " --host ", diff --git a/ts/tools/scripts/test/copilotManagedRuntime.spec.mjs b/ts/tools/scripts/test/copilotManagedRuntime.spec.mjs index 38200a223b..552e48a4ae 100644 --- a/ts/tools/scripts/test/copilotManagedRuntime.spec.mjs +++ b/ts/tools/scripts/test/copilotManagedRuntime.spec.mjs @@ -13,6 +13,7 @@ import { createCopilotRuntimeManifest, } from "../copilotRuntimeManifest.mjs"; import { + defaultRuntimeRoot, globalCopilotPackageVersion, managedRuntimeDirectory, npmInstallArgs, @@ -20,13 +21,207 @@ import { npmViewArgs, readCopilotRuntimeManifest, resolveInstalledCopilotPath, + setupStatePath, transientNpmrcContent, } from "../copilotRuntime.mjs"; +import { bundleEntry } from "../bundleUtils.mjs"; import { listedEntryState } from "../../installers/common/register-plugin.mjs"; const testDir = path.dirname(fileURLToPath(import.meta.url)); const tsRoot = path.resolve(testDir, "..", "..", ".."); +test("managed cache defaults ignore the application asset root", () => { + const dataHome = path.join(os.tmpdir(), "ta-data-home"); + const appRoot = path.join(os.tmpdir(), "ta-application"); + const env = { + TYPEAGENT_RUNTIME_ROOT: appRoot, + LOCALAPPDATA: dataHome, + XDG_DATA_HOME: dataHome, + }; + assert.equal( + defaultRuntimeRoot(env), + path.join(dataHome, "TypeAgent", "runtimes"), + ); + assert.equal( + defaultRuntimeRoot({ + ...env, + TYPEAGENT_COPILOT_RUNTIME_ROOT: "custom-cache", + }), + path.resolve("custom-cache"), + ); + assert.equal( + defaultRuntimeRoot({ TYPEAGENT_RUNTIME_ROOT: appRoot }), + path.join(os.homedir(), ".local", "share", "TypeAgent", "runtimes"), + ); +}); + +test("bundled runtime helper uses a separate managed cache and verifies startup", async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ta-copilot-bundle-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const appRoot = path.join(root, "agent-server"); + const dataHome = path.join(root, "profile"); + const runtimeRoot = path.join(dataHome, "TypeAgent", "runtimes"); + const manifest = createCopilotRuntimeManifest({ + platform: process.platform, + arch: process.arch, + }); + const packageDir = path.join( + managedRuntimeDirectory(manifest, runtimeRoot), + "node_modules", + ...manifest.platformPackage.split("/"), + ); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: manifest.platformPackage, + version: manifest.platformVersion, + }), + ); + const executable = path.join(packageDir, manifest.executablePath); + const runtimeLibrary = path.join(packageDir, manifest.runtimeLibraryPath); + fs.mkdirSync(path.dirname(executable), { recursive: true }); + fs.writeFileSync(executable, "fixture executable"); + fs.writeFileSync(runtimeLibrary, "fixture library"); + + const sdkDir = path.join(appRoot, "node_modules", "@github", "copilot-sdk"); + fs.mkdirSync(sdkDir, { recursive: true }); + fs.writeFileSync( + path.join(sdkDir, "package.json"), + JSON.stringify({ type: "module", exports: "./index.js" }), + ); + fs.writeFileSync( + path.join(sdkDir, "index.js"), + `import fs from "node:fs"; +function record(stage) { + fs.appendFileSync(process.env.TEST_RUNTIME_EVENTS, stage + "\\n"); + if (process.env.TEST_RUNTIME_FAILURE === stage) { + throw new Error("Runtime verification failed at " + stage); + } +} +export const RuntimeConnection = { forStdio: (options) => ({ kind: "stdio", ...options }) }; +export class CopilotClient { + constructor(options) { + if (options.connection.path !== process.env.TEST_RUNTIME_EXECUTABLE) { + throw new Error("Wrong managed runtime selected"); + } + } + async start() { record("start"); } + async getStatus() { record("status"); return { version: "${manifest.runtimeVersion}", protocolVersion: 3 }; } + async getAuthStatus() { record("auth"); return { isAuthenticated: false }; } + async listModels() { throw new Error("Model access is not required"); } + async stop() { record("stop"); } +}`, + ); + fs.writeFileSync( + path.join(appRoot, "copilot-runtime.json"), + JSON.stringify(manifest), + ); + const helper = path.join(appRoot, "tools", "copilotRuntime.mjs"); + await bundleEntry({ + entryPoint: path.join(tsRoot, "tools", "scripts", "copilotRuntime.mjs"), + outfile: helper, + runtimeRootFromOutput: "../", + }); + const events = path.join(root, "events.txt"); + const env = { + ...process.env, + LOCALAPPDATA: dataHome, + XDG_DATA_HOME: dataHome, + TEST_RUNTIME_EVENTS: events, + TEST_RUNTIME_EXECUTABLE: executable, + }; + delete env.TYPEAGENT_RUNTIME_ROOT; + delete env.TYPEAGENT_COPILOT_RUNTIME_ROOT; + delete env.TEST_RUNTIME_FAILURE; + const invoke = (args, overrides = {}) => + spawnSync(process.execPath, [helper, ...args], { + encoding: "utf8", + env: { ...env, ...overrides }, + timeout: 30_000, + }); + const readEvents = () => fs.readFileSync(events, "utf8").trim().split("\n"); + + await t.test( + "bundle banner does not redirect default cache discovery", + () => { + const result = invoke(["path"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), executable); + assert.equal( + fs.existsSync( + path.join( + appRoot, + "node_modules", + manifest.platformPackage, + ), + ), + false, + ); + }, + ); + await t.test("dedicated cache setting overrides platform defaults", () => { + const result = invoke(["path"], { + LOCALAPPDATA: path.join(root, "unused-profile"), + XDG_DATA_HOME: path.join(root, "unused-profile"), + TYPEAGENT_COPILOT_RUNTIME_ROOT: runtimeRoot, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), executable); + }); + await t.test("explicit cache argument overrides the environment", () => { + const result = invoke(["path", "--runtime-root", runtimeRoot], { + TYPEAGENT_COPILOT_RUNTIME_ROOT: path.join(root, "unused-cache"), + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), executable); + }); + for (const flag of ["--runtime-only", "--skip-login"]) { + await t.test( + `${flag} verifies startup without querying auth or models`, + () => { + fs.writeFileSync(events, ""); + const result = invoke(["setup", flag, "--non-interactive"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /runtime startup verified/); + assert.deepEqual(readEvents(), ["start", "status", "stop"]); + }, + ); + } + for (const stage of ["start", "status"]) { + await t.test( + `failed ${stage} reports incomplete setup and stops the client`, + () => { + fs.writeFileSync(events, ""); + const result = invoke( + ["setup", "--runtime-only", "--non-interactive"], + { TEST_RUNTIME_FAILURE: stage }, + ); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /Runtime verification failed/); + assert.doesNotMatch(result.stdout, /runtime startup verified/); + assert.deepEqual( + readEvents(), + stage === "start" + ? ["start", "stop"] + : ["start", "status", "stop"], + ); + }, + ); + } + await t.test("normal setup still requires sign-in", () => { + fs.writeFileSync(events, ""); + const result = invoke(["setup", "--non-interactive"]); + assert.equal(result.status, 2, result.stderr); + assert.match(result.stderr, /sign-in is required/); + assert.deepEqual(readEvents(), ["start", "status", "auth", "stop"]); + const state = JSON.parse( + fs.readFileSync(setupStatePath(runtimeRoot), "utf8"), + ); + assert.equal(state.status, "auth-required"); + }); +}); + test("manifest records the resolved SDK-compatible Windows runtime", () => { const manifest = createCopilotRuntimeManifest({ platform: "win32", @@ -252,6 +447,11 @@ test("installer integration uses managed setup without adding UI properties", () prereqs, /setup --provider copilot --runtime-only --non-interactive/, ); + assert.match( + prereqs, + /if \(\$LASTEXITCODE -ne 0\) \{\s*Write-Log " WARNING: Copilot runtime setup is incomplete\.[^\n]*\s*return \$null/, + ); + assert.match(prereqs, /exit 0\s*$/); assert.doesNotMatch( prereqs, /Install-Cli\s+"copilot"\s+"@github\/copilot"/,