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
29 changes: 26 additions & 3 deletions packages/core/src/services/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,31 @@ export async function assertCopilotCliReady(): Promise<CopilotCliConfig> {
export async function listCopilotModels(): Promise<string[]> {
const config = await assertCopilotCliReady();
const [cmd, args] = buildExecArgs(config, ["--help"]);
const { stdout } = await execFileAsync(cmd, args, { timeout: 5000 });
return extractModelChoices(stdout);
let stdout = "";
let stderr = "";
let execError: unknown = null;
try {
const result = await execFileAsync(cmd, args, { timeout: 5000 });
stdout = result.stdout;
stderr = result.stderr;
} catch (err) {
// Some CLIs exit with a non-zero code for --help; try to extract from stderr
execError = err;
const e = err as { stderr?: string; stdout?: string };
stdout = e.stdout ?? "";
stderr = e.stderr ?? "";
}
const fromStdout = extractModelChoices(stdout);
if (fromStdout.length > 0) return fromStdout;
const fromStderr = extractModelChoices(stderr);
if (fromStderr.length > 0) return fromStderr;
if (execError) {
const e = execError as Error & { stderr?: string; stdout?: string };
const details = e.stderr || e.stdout;
const detailMsg = details ? `\nCopilot CLI output:\n${details}` : "";
throw new Error(`Failed to list Copilot models: ${e.message}${detailMsg}`);
}
return [];
}

export function buildExecArgs(config: CopilotCliConfig, extraArgs: string[]): [string, string[]] {
Expand Down Expand Up @@ -274,7 +297,7 @@ async function isHeadlessCompatible(config: CopilotCliConfig): Promise<boolean>
}
}

function extractModelChoices(helpText: string): string[] {
export function extractModelChoices(helpText: string): string[] {
const lines = helpText.split("\n");
let captured = "";

Expand Down
37 changes: 37 additions & 0 deletions src/services/__tests__/copilot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { extractModelChoices } from "@agentrc/core/services/copilot";
import { describe, expect, it } from "vitest";

describe("extractModelChoices", () => {
it("extracts model names from a single-line --help output", () => {
const help =
' --model <model> Model to use (choices: "claude-sonnet-4.5", "claude-sonnet-4", "gpt-4.1")';
expect(extractModelChoices(help)).toEqual(["claude-sonnet-4.5", "claude-sonnet-4", "gpt-4.1"]);
});

it("extracts model names when choices span multiple lines", () => {
const help = [
' --model <model> Model to use (choices: "claude-sonnet-4.5",',
' "claude-sonnet-4", "gpt-4.1")'
].join("\n");
expect(extractModelChoices(help)).toEqual(["claude-sonnet-4.5", "claude-sonnet-4", "gpt-4.1"]);
});

it("returns empty array when --model line is absent", () => {
const help = " --output <file> Output file\n --quiet Suppress output";
expect(extractModelChoices(help)).toEqual([]);
});

it("returns empty array for empty string", () => {
expect(extractModelChoices("")).toEqual([]);
});

it("returns empty array when choices keyword is missing", () => {
const help = " --model <model> Model to use (default: claude-sonnet-4.5)";
expect(extractModelChoices(help)).toEqual([]);
});

it("handles help text written to stderr (same format)", () => {
const stderr = ' --model <model> Model (choices: "gpt-5", "gpt-4.1", "claude-sonnet-4.5")';
expect(extractModelChoices(stderr)).toEqual(["gpt-5", "gpt-4.1", "claude-sonnet-4.5"]);
});
});
10 changes: 5 additions & 5 deletions src/ui/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,18 +273,18 @@ export function AgentRCTui({ repoPath, skipAnimation = false }: Props): React.JS
listCopilotModels()
.then((models) => {
if (!active) return;
setAvailableModels(models);
if (models.length === 0) return;
const list = models.length > 0 ? models : PREFERRED_MODELS;
setAvailableModels(list);
setEvalModel((current) =>
models.includes(current) ? current : pickBestModel(models, current)
list.includes(current) ? current : pickBestModel(list, current)
);
setJudgeModel((current) =>
models.includes(current) ? current : pickBestModel(models, current)
list.includes(current) ? current : pickBestModel(list, current)
);
})
.catch(() => {
if (!active) return;
setAvailableModels([]);
setAvailableModels(PREFERRED_MODELS);
});
return () => {
active = false;
Expand Down
Loading