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 @@ -117,6 +117,9 @@ export async function runCopilotCliAgent(params: {
const result = await runCopilotAgent({
prompt: params.prompt,
model: modelId === "default" ? undefined : modelId,
// When resuming an existing session, use modelOverride to dynamically
// switch the model via setModel() instead of requiring a new session.
modelOverride: params.cliSessionId && modelId !== "default" ? modelId : undefined,
workspaceDir: resolvedWorkspace,
systemPrompt,
timeoutMs: params.timeoutMs,
Expand All @@ -133,7 +136,7 @@ export async function runCopilotCliAgent(params: {
agentMeta: {
sessionId: result.sessionId ?? params.sessionId ?? "",
provider: "copilot-cli",
model: modelId,
model: result.model ?? modelId,
},
},
};
Expand Down
91 changes: 91 additions & 0 deletions src/agents/copilot-sdk.dynamic-model-switch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// Mock the @github/copilot-sdk module
const mockSetModel = vi.fn().mockResolvedValue(undefined);
const mockSendAndWait = vi.fn().mockResolvedValue({ data: { content: "hello" } });
const mockDestroy = vi.fn().mockResolvedValue(undefined);
const mockStop = vi.fn().mockResolvedValue(undefined);
const mockGetAuthStatus = vi.fn().mockResolvedValue({
isAuthenticated: true,
authType: "token",
login: "test-user",
});
const mockCreateSession = vi.fn().mockResolvedValue({
sessionId: "sess-new",
setModel: mockSetModel,
sendAndWait: mockSendAndWait,
destroy: mockDestroy,
});
const mockResumeSession = vi.fn().mockResolvedValue({
sessionId: "sess-resumed",
setModel: mockSetModel,
sendAndWait: mockSendAndWait,
destroy: mockDestroy,
});

vi.mock("@github/copilot-sdk", () => {
const MockClient = vi.fn(function (this: Record<string, unknown>) {
this.getAuthStatus = mockGetAuthStatus;
this.createSession = mockCreateSession;
this.resumeSession = mockResumeSession;
this.listModels = vi.fn().mockResolvedValue([]);
this.stop = mockStop;
});
return { CopilotClient: MockClient };
});

// Must import after mock setup
const { runCopilotAgent } = await import("./copilot-sdk.js");

beforeEach(() => {
vi.clearAllMocks();
mockSendAndWait.mockResolvedValue({ data: { content: "response" } });
});

describe("runCopilotAgent dynamic model switching", () => {
it("does not call setModel when modelOverride is not set", async () => {
const result = await runCopilotAgent({
prompt: "hi",
model: "gpt-4o",
});

expect(mockSetModel).not.toHaveBeenCalled();
expect(result.model).toBe("gpt-4o");
expect(result.text).toBe("response");
});

it("calls setModel when modelOverride is provided on new session", async () => {
const result = await runCopilotAgent({
prompt: "hi",
model: "gpt-4o",
modelOverride: "claude-sonnet-4",
});

expect(mockSetModel).toHaveBeenCalledWith("claude-sonnet-4");
expect(result.model).toBe("claude-sonnet-4");
expect(result.sessionId).toBe("sess-new");
});

it("calls setModel when modelOverride is provided on resumed session", async () => {
const result = await runCopilotAgent({
prompt: "hi",
model: "gpt-4o",
modelOverride: "claude-sonnet-4",
sessionId: "existing-session",
});

expect(mockResumeSession).toHaveBeenCalledWith("existing-session", expect.any(Object));
expect(mockSetModel).toHaveBeenCalledWith("claude-sonnet-4");
expect(result.model).toBe("claude-sonnet-4");
expect(result.sessionId).toBe("sess-resumed");
});

it("returns model as undefined when no model or override specified", async () => {
const result = await runCopilotAgent({
prompt: "hi",
});

expect(mockSetModel).not.toHaveBeenCalled();
expect(result.model).toBeUndefined();
});
});
20 changes: 18 additions & 2 deletions src/agents/copilot-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export async function listCopilotModels(options?: { cwd?: string }): Promise<Mod
export type CopilotAgentRunOptions = {
prompt: string;
model?: string;
/** Switch to a different model mid-session (calls session.setModel() after create/resume). */
modelOverride?: string;
workspaceDir?: string;
systemPrompt?: string;
timeoutMs?: number;
Expand All @@ -121,6 +123,8 @@ export type CopilotAgentRunOptions = {
export type CopilotAgentRunResult = {
text: string;
sessionId: string;
/** The model actually used (may differ from requested if setModel() was called). */
model?: string;
};

/**
Expand Down Expand Up @@ -164,6 +168,18 @@ export async function runCopilotAgent(
session = await client.createSession(sessionConfig);
}

// Apply dynamic model switch if requested
let activeModel = options.model;
if (options.modelOverride) {
log.info("switching copilot session model", {
sessionId: session.sessionId,
from: options.model ?? "default",
to: options.modelOverride,
});
await session.setModel(options.modelOverride);
activeModel = options.modelOverride;
}

const timeoutMs = options.timeoutMs ?? 120_000;
const response = await session.sendAndWait({ prompt: options.prompt }, timeoutMs);

Expand All @@ -172,11 +188,11 @@ export async function runCopilotAgent(

log.info(`copilot agent run completed`, {
sessionId,
model: options.model,
model: activeModel,
responseLength: text.length,
});

return { text, sessionId };
return { text, sessionId, model: activeModel };
} finally {
if (session) {
try {
Expand Down
Loading