Skip to content
Merged
394 changes: 391 additions & 3 deletions actions/setup/js/copilot_harness.cjs

Large diffs are not rendered by default.

648 changes: 648 additions & 0 deletions actions/setup/js/copilot_harness.test.cjs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions actions/setup/js/copilot_sdk_driver.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* COPILOT_MODEL — model override (optional)
* GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON — JSON-encoded multi-provider config (required).
* Shape: { model, providers: NamedProviderConfig[], models: ProviderModelConfig[] }
* GH_AW_COPILOT_SDK_SUBAGENT_MODELS — JSON map of custom agent name → model resolved by the harness (optional)
* GH_AW_COPILOT_SDK_SERVER_ARGS — JSON-encoded allow-tool sidecar args (set by the engine)
*
* The sidecar is started and stopped by the harness; the driver only opens a
Expand Down
54 changes: 43 additions & 11 deletions actions/setup/js/copilot_sdk_driver.test.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
import { createRequire } from "module";
import * as fs from "fs";
import * as path from "path";
Expand Down Expand Up @@ -31,6 +31,10 @@ describe("copilot_sdk_driver.cjs", () => {
});

describe("runWithCopilotSDK", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it.each([
["gpt-5.4", "claude-sonnet-4.6", "copilot-responses", "copilot-completions"],
["claude-sonnet-4.6", "gpt-5.4", "copilot-completions", "copilot-responses"],
Expand All @@ -43,9 +47,10 @@ describe("copilot_sdk_driver.cjs", () => {
path.join(workspace, ".github/agents/researcher.agent.md"),
`---\nname: researcher\ndescription: >-\n Research across\n providers\ntools:\n - read\n - grep\nmodel: copilot/${agentModel}\n---\nResearch carefully.\n`
);
fs.writeFileSync(path.join(workflow, ".github/agents/workflow.agent.md"), "---\nname: workflow-agent\nmodel: medium\ntools: read, grep\n---\nWorkflow prompt.");
fs.writeFileSync(path.join(workflow, ".github/agents/workflow.agent.md"), "---\nname: workflow-agent\ntools: read, grep\n---\nWorkflow prompt.");
for (const alias of ["small", "large"]) fs.writeFileSync(path.join(workflow, `.github/agents/${alias}.agent.md`), `---\nmodel: ${alias}\n---\nAlias prompt.`);
fs.writeFileSync(path.join(workflow, ".github/agents/unknown.agent.md"), "---\nname: unknown-agent\nmodel: unavailable-model\n---\nKeep this agent.");
// The harness resolves aliases before the driver starts; the driver uses those models, not the session model.
vi.stubEnv("GH_AW_COPILOT_SDK_SUBAGENT_MODELS", JSON.stringify({ small: `${agentProvider}/${agentModel}`, large: agentModel }));
const createSession = vi.fn().mockResolvedValue({
sessionId: `${sessionProvider}-agent-session`,
on: () => {},
Expand Down Expand Up @@ -90,21 +95,48 @@ describe("copilot_sdk_driver.cjs", () => {
customAgents: expect.arrayContaining([
{ name: "researcher", displayName: "researcher", description: "Research across providers", tools: ["read", "grep"], prompt: "Research carefully.", model: `${agentProvider}/${agentModel}` },
{ name: "workflow-agent", displayName: "workflow-agent", tools: ["read", "grep"], prompt: "Workflow prompt.", model: `${sessionProvider}/${sessionModel}` },
{ name: "unknown-agent", displayName: "unknown-agent", prompt: "Keep this agent.", model: `${sessionProvider}/${sessionModel}` },
{ name: "small", displayName: "small", prompt: "Alias prompt.", model: `${sessionProvider}/${sessionModel}` },
{ name: "large", displayName: "large", prompt: "Alias prompt.", model: `${sessionProvider}/${sessionModel}` },
{ name: "small", displayName: "small", prompt: "Alias prompt.", model: `${agentProvider}/${agentModel}` },
{ name: "large", displayName: "large", prompt: "Alias prompt.", model: `${agentProvider}/${agentModel}` },
]),
})
);
expect(logger).toHaveBeenCalledWith(expect.stringMatching(/warning:.*unknown-agent.*unavailable-model.*session model/i));
});

it.each(["medium", "GPT-5.4 (copilot)"])("gives a repository agent whose model %s the harness could not resolve the session model with a warning", async agentModel => {
const workflow = path.join(testSessionStateDir, `unresolved-${agentModel.replace(/\W+/g, "-")}-workflow`);
fs.mkdirSync(path.join(workflow, ".github/agents"), { recursive: true });
fs.writeFileSync(path.join(workflow, ".github/agents/unknown.agent.md"), `---\nname: unknown-agent\nmodel: ${agentModel}\n---\nUnknown model.`);
// The harness recorded the failure and left the repository agent out of the resolved map.
vi.stubEnv("GH_AW_COPILOT_SDK_SUBAGENT_MODELS", "{}");
const sessionId = `unresolved-${agentModel.replace(/\W+/g, "-")}-session`;
const createSession = vi.fn().mockResolvedValue({ sessionId, on: () => {}, sendAndWait: vi.fn().mockResolvedValue({ data: { content: "done" } }), disconnect: vi.fn().mockResolvedValue(undefined) });
class FakeCopilotClient {
start = vi.fn().mockResolvedValue(undefined);
createSession = createSession;
stop = vi.fn().mockResolvedValue(undefined);
}
const logger = vi.fn();
const result = await runWithCopilotSDK({
sdkUri: "http://127.0.0.1:3002",
prompt: "test prompt",
model: "copilot/gpt-5.4",
models: [{ id: "gpt-5.4", provider: "copilot-responses" }],
providers: [{ name: "copilot-responses", type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "responses" }],
workingDirectory: workflow,
agentsBaseDir: workflow,
bare: true,
logger,
sdkModule: { CopilotClient: FakeCopilotClient, RuntimeConnection: { forUri: vi.fn(() => ({})) }, approveAll: () => "allow" },
});
expect(result.exitCode).toBe(0);
expect(createSession.mock.calls[0][0].customAgents).toEqual([{ name: "unknown-agent", displayName: "unknown-agent", prompt: "Unknown model.", model: "copilot-responses/gpt-5.4" }]);
expect(logger).toHaveBeenCalledWith(expect.stringMatching(/warning:.*unknown-agent.*session model/i));
const captured = fs
.readFileSync(path.join(testSessionStateDir, `${sessionProvider}-agent-session/events.jsonl`), "utf8")
.readFileSync(path.join(testSessionStateDir, `${sessionId}/events.jsonl`), "utf8")
.trim()
.split("\n")
.map(JSON.parse);
expect(captured.filter(event => event.type === "subagent.model_unavailable")).toEqual([
expect.objectContaining({ data: { agentName: "unknown-agent", declaredModel: "unavailable-model", model: `${sessionProvider}/${sessionModel}` } }),
]);
expect(captured.filter(event => event.type === "subagent.model_unavailable")).toEqual([expect.objectContaining({ data: { agentName: "unknown-agent", declaredModel: agentModel, model: "copilot-responses/gpt-5.4" } })]);
});

it("captures ephemeral selection and final model results including BYOK and subagent context", async () => {
Expand Down
36 changes: 29 additions & 7 deletions actions/setup/js/copilot_sdk_session.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ function resolveCopilotSDKWorkingDirectory() {
return path.resolve(process.env.GITHUB_WORKSPACE || process.cwd(), process.env.GH_AW_ENGINE_CWD || ".");
}

/**
* Parse GH_AW_COPILOT_SDK_SUBAGENT_MODELS: custom agent name → model resolved by the harness.
* @param {string | undefined} value
* @returns {Record<string, string>}
*/
function parseSubagentModelsJson(value) {
if (!value) return {};
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Object.values(parsed).every(model => typeof model === "string")) {
throw new Error("GH_AW_COPILOT_SDK_SUBAGENT_MODELS must map custom agent names to models");
}
return parsed;
}

/**
* Run a Copilot agentic session using the @github/copilot-sdk.
*
Expand Down Expand Up @@ -369,16 +383,24 @@ async function runWithCopilotSDK({
const providerConfig = providers && providerModels ? { providers, models: providerModels } : null;
const sessionModel = providerConfig ? qualifyModelForMultiProvider(requestedModel, providerConfig) : requestedModel;
if (providerConfig && !sessionModel) throw new Error(`Cannot qualify session model "${requestedModel || "(none)"}" against the configured provider models`);
// The harness resolves sub-agent models (aliases included) before the driver starts and
// stops the run when a workflow-declared sub-agent fails. A repository agent whose model
// the harness could not resolve is absent from the map and inherits the session model.
const resolvedSubagentModels = parseSubagentModelsJson(process.env.GH_AW_COPILOT_SDK_SUBAGENT_MODELS);
const unavailableModels = [];
const customAgents = loadCopilotSDKCustomAgents(workingDirectory, agentsBaseDir, log).map(agent => {
const requested = agent.model;
const alias = !requested || ["small", "medium", "large"].includes(requested.toLowerCase());
const qualified = alias ? sessionModel : providerConfig ? qualifyModelForMultiProvider(requested, providerConfig) : requested;
if (!alias && !qualified) {
log(`warning: custom agent "${agent.name}" model "${requested}" is not in the configured provider models; using session model "${sessionModel || "(none)"}"`);
unavailableModels.push({ agentName: agent.name, declaredModel: requested, model: sessionModel || undefined });
if (!agent.model || agent.model.trim().toLowerCase() === "inherited") return { ...agent, model: sessionModel || undefined };
if (Object.prototype.hasOwnProperty.call(resolvedSubagentModels, agent.name)) {
const resolved = resolvedSubagentModels[agent.name];
const qualified = providerConfig ? qualifyModelForMultiProvider(resolved, providerConfig) : resolved;
if (!qualified) throw new Error(`Cannot qualify custom agent "${agent.name}" model "${resolved}" against the configured provider models`);
return { ...agent, model: qualified };
}
return { ...agent, model: qualified || sessionModel || undefined };
const qualified = providerConfig ? qualifyModelForMultiProvider(agent.model, providerConfig) : agent.model;
if (qualified) return { ...agent, model: qualified };
log(`warning: custom agent "${agent.name}" model "${agent.model}" is not in the configured provider models; using session model "${sessionModel || "(none)"}"`);
unavailableModels.push({ agentName: agent.name, declaredModel: agent.model, model: sessionModel || undefined });
return { ...agent, model: sessionModel || undefined };
});
const skillsDirectory = path.resolve(agentsBaseDir, ".github", "skills");
const skillsStat = fs.lstatSync(skillsDirectory, { throwIfNoEntry: false });
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/copilot_sdk_session.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ describe("copilot_sdk_session runtime configuration", () => {
const app = path.join(base, "packages/app");
fs.mkdirSync(path.join(app, ".github/agents"), { recursive: true });
fs.writeFileSync(path.join(app, ".github/agents/local.agent.md"), "---\nmodel: small\n---\nApp agent.");
// The harness resolved `small` before the driver started.
vi.stubEnv("GH_AW_COPILOT_SDK_SUBAGENT_MODELS", JSON.stringify({ local: "claude-sonnet-4.6" }));
vi.stubEnv("GITHUB_WORKSPACE", base);
vi.spyOn(process, "cwd").mockReturnValue(app);
const createSession = vi.fn().mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"apiProxy": {
"models": {
"agent": ["sonnet-6x", "gpt-6", "gpt-5.4", "gpt-5.5", "gpt-5.6", "gpt-5.3", "gemini-pro", "any"],
"antigravity": ["copilot/antigravity*", "google/antigravity*", "gemini/antigravity*"],
"any": ["copilot/*", "anthropic/*", "openai/*", "google/*", "gemini/*"],
"auto": ["copilot/auto", "large"],
"claude": ["agent"],
"codex": ["agent"],
"coding": ["copilot/gpt-5*codex*", "openai/gpt-5*codex*", "gpt-5-codex", "kimi"],
"computer-use": ["copilot/*computer-use*", "google/*computer-use*", "gemini/*computer-use*", "openai/*computer-use*"],
"copilot": ["agent"],
"deep-research": ["copilot/deep-research*", "copilot/o3-deep-research*", "copilot/o4-mini-deep-research*", "google/deep-research*", "gemini/deep-research*", "openai/o3-deep-research*", "openai/o4-mini-deep-research*"],
"detection": ["small"],
"evals": ["small"],
"fable": ["copilot/*fable*", "anthropic/*fable*"],
"gemini": ["agent"],
"gemini-3-flash": ["copilot/gemini-3*flash*", "google/gemini-3*flash*", "gemini/gemini-3*flash*"],
"gemini-3-pro": ["copilot/gemini-3*pro*", "google/gemini-3*pro*", "google/nano-banana*", "gemini/gemini-3*pro*"],
"gemini-3.1-flash": ["copilot/gemini-3.1*flash*", "google/gemini-3.1*flash*", "gemini/gemini-3.1*flash*"],
"gemini-3.1-pro": ["copilot/gemini-3.1*pro*", "google/gemini-3.1*pro*", "gemini/gemini-3.1*pro*"],
"gemini-3.5-flash": ["copilot/gemini-3.5*flash*", "google/gemini-3.5*flash*", "gemini/gemini-3.5*flash*"],
"gemini-3.6-flash": ["copilot/gemini-3.6*flash*", "google/gemini-3.6*flash*", "gemini/gemini-3.6*flash*"],
"gemini-3.7-flash": ["copilot/gemini-3.7*flash*", "google/gemini-3.7*flash*", "gemini/gemini-3.7*flash*"],
"gemini-3.8-flash": ["copilot/gemini-3.8*flash*", "google/gemini-3.8*flash*", "gemini/gemini-3.8*flash*"],
"gemini-flash": ["copilot/gemini-*flash*", "google/gemini-*flash*", "gemini/gemini-*flash*"],
"gemini-flash-lite": ["copilot/gemini-*flash*lite*", "google/gemini-*flash*lite*", "gemini/gemini-*flash*lite*"],
"gemini-omni": ["copilot/gemini-omni*", "google/gemini-omni*", "gemini/gemini-omni*"],
"gemini-pro": ["copilot/gemini-*pro*", "google/gemini-*pro*", "gemini/gemini-*pro*"],
"gemma": ["copilot/gemma*", "google/gemma*", "gemini/gemma*"],
"gpt-5": ["copilot/gpt-5*", "openai/gpt-5*"],
"gpt-5-codex": ["copilot/gpt-5*codex*", "openai/gpt-5*codex*"],
"gpt-5-mini": ["copilot/gpt-5*mini*", "openai/gpt-5*mini*"],
"gpt-5-nano": ["copilot/gpt-5*nano*", "openai/gpt-5*nano*"],
"gpt-5-pro": ["copilot/gpt-5*pro*", "openai/gpt-5*pro*"],
"gpt-5.1": ["copilot/gpt-5.1*", "openai/gpt-5.1*"],
"gpt-5.2": ["copilot/gpt-5.2*", "openai/gpt-5.2*"],
"gpt-5.3": ["copilot/gpt-5.3*", "openai/gpt-5.3*"],
"gpt-5.4": ["copilot/gpt-5.4*", "openai/gpt-5.4*"],
"gpt-5.5": ["copilot/gpt-5.5*", "openai/gpt-5.5*"],
"gpt-5.6": ["copilot/gpt-5.6*", "openai/gpt-5.6*"],
"gpt-6": ["copilot/gpt-6*", "openai/gpt-6*"],
"grok": ["copilot/*grok*", "openai/*grok*"],
"haiku": ["copilot/*haiku*", "anthropic/*haiku*"],
"image-generation": ["copilot/gpt-image*", "openai/gpt-image*", "openai/chatgpt-image*", "copilot/gemini-*image*", "google/gemini-*image*", "gemini/gemini-*image*", "google/imagen*"],
"kimi": ["copilot/kimi*", "openai/kimi*"],
"kiwi": ["copilot/kiwi*", "openai/kiwi*"],
"large": ["sonnet", "gpt-6", "gpt-5-pro", "gpt-5", "gemini-pro"],
"lyria": ["google/lyria*", "gemini/lyria*", "copilot/lyria*"],
"mai-code": ["copilot/MAI-Code*", "copilot/mai-code*", "openai/MAI-Code*"],
"mini": ["haiku", "gpt-5-mini", "gpt-5-nano", "gemini-flash-lite"],
"nano-banana": ["copilot/nano-banana*", "google/nano-banana*", "gemini/nano-banana*"],
"opus": ["copilot/*opus*", "anthropic/*opus*"],
"opusplan": ["opus?effort=high"],
"raptor-mini": ["copilot/raptor*", "openai/raptor*"],
"reasoning": ["copilot/o1*", "copilot/o3*", "copilot/o4*", "openai/o1*", "openai/o3*", "openai/o4*"],
"robotics": ["copilot/*robotics*", "google/*robotics*", "gemini/*robotics*"],
"small": ["mini"],
"small-agent": ["haiku", "gpt-5-mini", "gemini-flash"],
"sonnet": ["copilot/*sonnet*", "anthropic/*sonnet*"],
"sonnet-6x": ["copilot/*sonnet-4.5*", "copilot/*sonnet-4.6*", "copilot/*sonnet-5*", "copilot/*sonnet-4-5-*", "anthropic/*sonnet-4-5-*", "copilot/*sonnet-4-6*", "anthropic/*sonnet-4-6*", "anthropic/*sonnet-5*"],
"summarization": ["haiku", "gpt-5-mini", "gemini-flash-lite", "mini"],
"veo": ["google/veo*", "gemini/veo*"],
"vision": ["copilot/gemini-*image*", "google/gemini-*image*", "gemini/gemini-*image*", "copilot/gemini-*flash*", "google/gemini-*flash*", "gemini/gemini-*flash*"]
}
}
}
Loading
Loading