Skip to content
Merged
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
30 changes: 30 additions & 0 deletions apps/server/src/provider/CopilotSdkImport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as Path from "effect/Path";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

it.effect("loads the Copilot SDK with native Node ESM resolution", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const executable = yield* HostProcessExecutablePath;
const cwd = yield* path.fromFileUrl(new URL("../../", import.meta.url));
// A transformed Vitest import accepts extensionless dependencies that Node
// rejects when the real provider dynamically imports this package.
const output = yield* spawner.string(
ChildProcess.make(
executable,
[
"--input-type=module",
"-e",
'import { CopilotClient } from "@github/copilot-sdk"; if (typeof CopilotClient !== "function") throw new Error("Missing CopilotClient"); process.stdout.write("copilot-sdk-loaded");',
],
{ cwd },
),
);
expect(output).toBe("copilot-sdk-loaded");
}).pipe(Effect.provide(NodeServices.layer)),
);
7 changes: 5 additions & 2 deletions apps/server/src/provider/Drivers/CopilotSettings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CustomModelSetting } from "@t3tools/contracts";
/**
* CopilotSettings — local typed config schema for the GitHub Copilot driver.
*
Expand All @@ -13,7 +14,7 @@
* - `binaryPath` — Path to the Copilot CLI binary; empty defaults to
* the bundled CLI (see `copilotCliPath.ts`).
* - `configDir` — Optional override for the Copilot config dir.
* - `customModels` — User-added model slugs.
* - `customModels` — User-added model slugs, names, and capability descriptors.
*/
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
Expand All @@ -22,6 +23,8 @@ export const CopilotSettings = Schema.Struct({
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
binaryPath: Schema.Trim.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
configDir: Schema.Trim.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
),
});
export type CopilotSettings = typeof CopilotSettings.Type;
3 changes: 2 additions & 1 deletion apps/server/src/provider/Drivers/StandardAcpCliDriver.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CustomModelSetting } from "@t3tools/contracts";
import {
type ProviderDriverKind,
type ServerProvider,
Expand Down Expand Up @@ -46,7 +47,7 @@ export interface StandardAcpCliSettings {
readonly enabled: boolean;
readonly binaryPath: string;
readonly arguments?: string;
readonly customModels: ReadonlyArray<string>;
readonly customModels: ReadonlyArray<CustomModelSetting>;
}

export type StandardAcpCliDriverEnv =
Expand Down
6 changes: 2 additions & 4 deletions apps/server/src/provider/Layers/CopilotAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,7 @@ modeLayer("CopilotAdapterLive interaction mode", (it) => {
NodeAssert.equal(adapter.capabilities.supportsConversationRollback, false);
}),
);
// Skip: @github/copilot-sdk has broken ESM resolution (vscode-jsonrpc/node) in CI
it.effect.skip("switches the Copilot session mode when interactionMode changes", () =>
it.effect("switches the Copilot session mode when interactionMode changes", () =>
Effect.gen(function* () {
modeSession.modeSetImpl.mockClear();
modeSession.sendImpl.mockClear();
Expand Down Expand Up @@ -193,8 +192,7 @@ const planLayer = it.layer(
);

planLayer("CopilotAdapterLive proposed plan events", (it) => {
// Skip: @github/copilot-sdk has broken ESM resolution (vscode-jsonrpc/node) in CI
it.effect.skip("emits a proposed-plan completion event from Copilot plan updates", () =>
it.effect("emits a proposed-plan completion event from Copilot plan updates", () =>
Effect.gen(function* () {
planSession.modeSetImpl.mockClear();
planSession.planReadImpl.mockReset();
Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/provider/Layers/CopilotAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import {
recordTurnUsage,
type CopilotTurnTrackingState,
} from "./copilotTurnTracking.ts";
import { resolveBundledCopilotCliPath, withSanitizedCopilotDesktopEnv } from "./copilotCliPath.ts";
import { resolveCopilotSdkCliPath, withSanitizedCopilotDesktopEnv } from "./copilotCliPath.ts";
import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts";
import { toMessage } from "../toMessage.ts";
import type {
Expand Down Expand Up @@ -1361,7 +1361,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* (
});
}
const settingsBinaryPath = copilotSettings.binaryPath.trim();
const cliPath = settingsBinaryPath || resolveBundledCopilotCliPath();
const cliPath = resolveCopilotSdkCliPath(settingsBinaryPath);
const configDir = trimToUndefined(copilotSettings.configDir);
const resumeSessionId = extractResumeSessionId(input.resumeCursor);
const clientOptions: CopilotClientOptions = {
Expand Down Expand Up @@ -1813,7 +1813,7 @@ export async function fetchCopilotModels(overrideCliPath?: string): Promise<Read
}> | null> {
try {
const { CopilotClient } = await import("@github/copilot-sdk");
const cliPath = overrideCliPath?.trim() || resolveBundledCopilotCliPath();
const cliPath = resolveCopilotSdkCliPath(overrideCliPath);
const client = new CopilotClient({
...(cliPath ? { cliPath } : {}),
logLevel: "error",
Expand Down Expand Up @@ -1850,7 +1850,7 @@ export async function fetchCopilotUsage(overrideCliPath?: string): Promise<{
}> {
try {
const { CopilotClient } = await import("@github/copilot-sdk");
const cliPath = overrideCliPath?.trim() || resolveBundledCopilotCliPath();
const cliPath = resolveCopilotSdkCliPath(overrideCliPath);
const client = new CopilotClient({
...(cliPath ? { cliPath } : {}),
logLevel: "error",
Expand Down
9 changes: 7 additions & 2 deletions apps/server/src/provider/Layers/CopilotProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import {
spawnAndCollect,
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { resolveBundledCopilotCliPath, withSanitizedCopilotDesktopEnv } from "./copilotCliPath.ts";
import {
resolveBundledCopilotCliPath,
resolveCopilotSdkCliPath,
withSanitizedCopilotDesktopEnv,
} from "./copilotCliPath.ts";
import type { CopilotSettings } from "../Drivers/CopilotSettings.ts";

const PROVIDER = ProviderDriverKind.make("copilot");
Expand Down Expand Up @@ -121,8 +125,9 @@ const probeCopilotAuth = (binaryPath: string | undefined): Effect.Effect<Copilot
Effect.tryPromise({
try: async (): Promise<CopilotAuthProbeResult> => {
const { CopilotClient } = await import("@github/copilot-sdk");
const cliPath = resolveCopilotSdkCliPath(binaryPath);
const client = new CopilotClient({
...(binaryPath ? { cliPath: binaryPath } : {}),
...(cliPath ? { cliPath } : {}),
logLevel: "error",
});
try {
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/Layers/StandardAcpCliProvider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CustomModelSetting } from "@t3tools/contracts";
import type {
ModelCapabilities,
ProviderDriverKind,
Expand Down Expand Up @@ -44,7 +45,7 @@ export interface StandardAcpCliProviderConfig {
readonly command: string;
readonly args?: ReadonlyArray<string>;
readonly enabled: boolean;
readonly customModels: ReadonlyArray<string>;
readonly customModels: ReadonlyArray<CustomModelSetting>;
readonly environment: NodeJS.ProcessEnv;
readonly setupHint: string;
readonly missingCommandMessage: string;
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/provider/Layers/copilotCliPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
resolveBundledCopilotCliPathFrom,
resolveCopilotSdkCliPath,
withSanitizedCopilotDesktopEnv,
} from "./copilotCliPath.ts";

Expand Down Expand Up @@ -94,3 +95,18 @@ describe("withSanitizedCopilotDesktopEnv", () => {
}
});
});

describe("resolveCopilotSdkCliPath", () => {
it("uses bundled SDK resolution for default command names", () => {
const bundled = resolveCopilotSdkCliPath();
for (const value of ["", "copilot", " copilot ", "copilot.exe", "copilot.cmd", "copilot.bat"]) {
expect(resolveCopilotSdkCliPath(value)).toBe(bundled);
}
expect(bundled).not.toBe("copilot");
});

it("preserves explicit configured executable paths", () => {
expect(resolveCopilotSdkCliPath(" /custom/copilot ")).toBe("/custom/copilot");
expect(resolveCopilotSdkCliPath("./copilot")).toBe("./copilot");
});
});
5 changes: 5 additions & 0 deletions apps/server/src/provider/Layers/copilotCliPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,8 @@ export function resolveBundledCopilotCliPath(): string | undefined {
...(sdkEntrypoint ? { sdkEntrypoint } : {}),
});
}

/** SDK cliPath is a filesystem path, not a command to search on PATH. */
export function resolveCopilotSdkCliPath(value?: string): string | undefined {
return normalizeCopilotCliPathOverride(value) ?? resolveBundledCopilotCliPath();
}
76 changes: 76 additions & 0 deletions apps/server/src/provider/customModelSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "@effect/vitest";
import * as Schema from "effect/Schema";
import * as Effect from "effect/Effect";
import { ProviderDriverKind } from "@t3tools/contracts";
import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts";
import { CopilotSettings } from "./Drivers/CopilotSettings.ts";
import { makePendingCopilotProvider } from "./Layers/CopilotProvider.ts";
import { buildInitialStandardAcpCliProviderSnapshot } from "./Layers/StandardAcpCliProvider.ts";

const capabilities = {
optionDescriptors: [
{
id: "reasoningEffort",
label: "Reasoning",
type: "select",
options: [{ id: "high", label: "High" }],
},
],
} as const;
const namedModel = { slug: "custom-preview", name: "Custom preview", capabilities };
const customModels = ["legacy-model", namedModel];

const decodeCopilotSettings = Schema.decodeUnknownSync(CopilotSettings);
const driverCases: Array<{
kind: string;
defaults: unknown;
decode: (input: unknown) => unknown;
encode: (input: unknown) => unknown;
}> = [];
for (const driver of BUILT_IN_DRIVERS) {
driverCases.push({
kind: driver.driverKind,
defaults: driver.defaultConfig(),
decode: Schema.decodeUnknownSync(driver.configSchema),
encode: Schema.encodeUnknownSync(driver.configSchema),
});
}

describe("built-in driver custom model settings", () => {
it.each(driverCases)(
"$kind accepts the model editor's structured entries alongside legacy strings",
({ defaults, decode, encode }) => {
const decoded = decode(Object.assign({}, defaults, { customModels }));
expect(decoded).toMatchObject({ customModels });
expect(encode(decoded)).toMatchObject({
customModels,
});
},
);

it("preserves custom names and capabilities in the Copilot model snapshot", () => {
const settings = decodeCopilotSettings({ customModels });
const models = makePendingCopilotProvider(settings).models;
expect(models).toContainEqual({ ...namedModel, isCustom: true });
expect(models.find((model) => model.slug === "legacy-model")?.name).toBe("legacy-model");
});

it.effect("preserves custom names and capabilities in the shared ACP model snapshot", () =>
Effect.gen(function* () {
const snapshot = yield* buildInitialStandardAcpCliProviderSnapshot({
provider: ProviderDriverKind.make("acp"),
displayName: "ACP Agent",
command: "unused",
enabled: false,
customModels,
environment: {},
setupHint: "Configure ACP",
missingCommandMessage: "Unavailable",
});
expect(snapshot.models).toContainEqual({ ...namedModel, isCustom: true });
expect(snapshot.models.find((model) => model.slug === "legacy-model")?.name).toBe(
"legacy-model",
);
}),
);
});
10 changes: 10 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ describe("custom model settings", () => {
]);
});

it.each(Object.keys(DEFAULT_SERVER_SETTINGS.providers))(
"round-trips named models and option descriptors in %s settings and patches",
(driver) => {
const customModels = ["legacy-model", { slug: "named", name: "Named model", capabilities }];
const input = { providers: { [driver]: { customModels } } };
expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
expect(decodeServerSettingsPatch(input)).toMatchObject(input);
},
);

it("accepts entries at the settings patch boundary", () => {
expect(
decodeServerSettingsPatch({
Expand Down
24 changes: 13 additions & 11 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,7 @@ export const DroidSettings = makeProviderSettingsSchema(
providerSettingsForm: { placeholder: "droid", clearWhenEmpty: "omit" },
}),
),
customModels: Schema.Array(Schema.String).pipe(
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
Expand Down Expand Up @@ -807,7 +807,7 @@ export const AmpSettings = makeProviderSettingsSchema(
},
}),
),
customModels: Schema.Array(Schema.String).pipe(
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
Expand Down Expand Up @@ -840,7 +840,7 @@ export const CopilotSettings = makeProviderSettingsSchema(
providerSettingsForm: { placeholder: "~/.copilot", clearWhenEmpty: "omit" },
}),
),
customModels: Schema.Array(Schema.String).pipe(
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
Expand Down Expand Up @@ -872,7 +872,7 @@ export const GeminiCliSettings = makeProviderSettingsSchema(
providerSettingsForm: { placeholder: "~/.gemini", clearWhenEmpty: "omit" },
}),
),
customModels: Schema.Array(Schema.String).pipe(
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
Expand Down Expand Up @@ -907,7 +907,7 @@ export const KiloSettings = makeProviderSettingsSchema(
},
}),
),
customModels: Schema.Array(Schema.String).pipe(
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
Expand Down Expand Up @@ -938,7 +938,7 @@ function makeAcpCliProviderSettingsSchema(input: {
},
}),
),
customModels: Schema.Array(Schema.String).pipe(
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
Expand Down Expand Up @@ -998,7 +998,7 @@ export const AcpSettings = makeProviderSettingsSchema(
},
}),
),
customModels: Schema.Array(Schema.String).pipe(
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
Expand All @@ -1009,7 +1009,9 @@ export type AcpSettings = typeof AcpSettings.Type;

export const GenericProviderSettings = Schema.Struct({
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
),
binaryPath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
configDir: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
});
Expand Down Expand Up @@ -1356,7 +1358,7 @@ const GenericProviderSettingsPatch = Schema.Struct({
enabled: Schema.optionalKey(Schema.Boolean),
binaryPath: Schema.optionalKey(Schema.String),
configDir: Schema.optionalKey(Schema.String),
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
});

const CursorSettingsPatch = Schema.Struct({
Expand Down Expand Up @@ -1393,13 +1395,13 @@ const OpenCodeSettingsPatch = Schema.Struct({
const DroidSettingsPatch = Schema.Struct({
enabled: Schema.optionalKey(Schema.Boolean),
binaryPath: Schema.optionalKey(TrimmedString),
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
});

const OhMyPiSettingsPatch = Schema.Struct({
enabled: Schema.optionalKey(Schema.Boolean),
binaryPath: Schema.optionalKey(TrimmedString),
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
});

export const ServerSettingsPatch = Schema.Struct({
Expand Down
Loading
Loading