diff --git a/CHANGELOG.md b/CHANGELOG.md index 237f395..dac40e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to FixLab are documented in this file. ## Unreleased +- Make **Copilot Default** the dashboard model selector's initial choice and + expose **HydraFusion** as an explicit research-preview alternative. +- Require the first FixLab stage marker before any model tool call so + HydraFusion reports dashboard stage progress instead of batching it at the + end of its orchestration. +- Parse adjacent rendered FixLab markers independently so model output + coalescing cannot hide later stages or make completed jobs appear incomplete. - Add an Agency-like `fixlab ` interactive entry point for conversational setup, diagnosis, repair, and validation. - Add `fixlab chat` as a terminal client for the active dashboard session, diff --git a/README.md b/README.md index 4b61f73..e9750f4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,17 @@ agency copilot For direct mode, install and authenticate GitHub Copilot CLI, then use `--runtime copilot` or set `FIXLAB_RUNTIME=copilot`. +Direct mode can use any Copilot CLI model identifier with `--model` or +`FIXLAB_MODEL`. HydraFusion is available as a research preview: + +```shell +fixlab chat --runtime copilot --model hydrafusion +``` + +FixLab automatically passes `--experimental` for HydraFusion. Use it for +substantial tasks that can be described completely in the first turn; model +availability remains controlled by the installed Copilot CLI and account. + ### 2. Install and initialize FixLab ```shell diff --git a/bin/fixlab.js b/bin/fixlab.js index 142b994..d49bbe0 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -20,7 +20,9 @@ import { createDashboardServer, DEFAULT_DASHBOARD_PORT, FIXLAB_RUNTIMES, + HYDRAFUSION_MODEL, inspectRepository, + validateCopilotModel, validateTargetEnvironment, validateLiveTestProfile, validatePort @@ -68,17 +70,17 @@ function printUsage() { console.log(`FixLab CLI Usage: - fixlab [--runtime ] - fixlab onboard [repository] [--yes] [--authenticate] [--start-dashboard] [--runtime ] + fixlab [--runtime ] [--model ] + fixlab onboard [repository] [--yes] [--authenticate] [--start-dashboard] [--runtime ] [--model ] fixlab init [repository] fixlab prepare [repository] [--yes] fixlab doctor [repository] [--runtime ] fixlab setup-playwright [repository] [--yes] fixlab authenticate [repository] [--yes] - fixlab run [repository] [--runtime ] [--environment ] [--] [request...] - fixlab validate [repository] --pr [--runtime ] - fixlab dashboard [repository] [--port ] [--no-open] [--runtime ] - fixlab chat [repository] [--port ] [--runtime ] + fixlab run [repository] [--runtime ] [--model ] [--environment ] [--] [request...] + fixlab validate [repository] --pr [--runtime ] [--model ] + fixlab dashboard [repository] [--port ] [--no-open] [--runtime ] [--model ] + fixlab chat [repository] [--port ] [--runtime ] [--model ] fixlab --help Commands: @@ -102,6 +104,8 @@ Runtime: copilot Use GitHub Copilot CLI directly. Set FIXLAB_RUNTIME or pass --runtime to select the runtime. +Set FIXLAB_MODEL or pass --model to select a GitHub Copilot CLI model. +HydraFusion is available in direct mode with --runtime copilot --model ${HYDRAFUSION_MODEL}. Run with --environment to select an allowed non-production environment from the repository profile. For the Agency-like interactive experience, run FixLab with only the repository path. @@ -460,6 +464,7 @@ function parseRuntimeArguments(args) { } catch (error) { return { error: `FIXLAB_RUNTIME ${error.message}` }; } + let model = process.env.FIXLAB_MODEL ?? ""; const remaining = []; let afterSeparator = false; for (let index = 0; index < args.length; index += 1) { @@ -489,9 +494,29 @@ function parseRuntimeArguments(args) { } continue; } + if (!afterSeparator && argument === "--model") { + if (!args[index + 1]) { + return { error: "--model requires a model identifier" }; + } + model = args[index + 1]; + index += 1; + continue; + } + if (!afterSeparator && argument.startsWith("--model=")) { + model = argument.slice("--model=".length); + if (!model) { + return { error: "--model requires a model identifier" }; + } + continue; + } remaining.push(argument); } - return { runtime, args: remaining }; + try { + model = validateCopilotModel(model, runtime); + } catch (error) { + return { error: error.message }; + } + return { runtime, model, args: remaining }; } function doctor(repository, runtime) { @@ -876,7 +901,13 @@ function authenticate(repository, approved) { return 0; } -function launch(repository, request, runtime, targetEnvironment = "") { +function launch( + repository, + request, + runtime, + model = "", + targetEnvironment = "" +) { const { profile, error } = loadProfile(repository); if (error) { console.error( @@ -930,7 +961,8 @@ function launch(repository, request, runtime, targetEnvironment = "") { ? buildCopilotInvocation({ packageRoot, prompt, - sessionId: randomUUID() + sessionId: randomUUID(), + model }) : buildAgencyInvocation({ packageRoot, @@ -944,6 +976,15 @@ function launch(repository, request, runtime, targetEnvironment = "") { packageRoot, "--agent", "fixlab:fixlab", + ...(runtime === "copilot" && model + ? [ + ...(model.toLowerCase() === HYDRAFUSION_MODEL + ? ["--experimental"] + : []), + "--model", + model + ] + : []), ...(prompt ? ["--interactive", prompt] : []) ] }; @@ -1112,6 +1153,7 @@ function parseOnboardArguments(args) { return { repository: resolveRepository(repositoryArgument), runtime: runtimeArguments.runtime, + model: runtimeArguments.model, approved, authenticateBrowser, startDashboard @@ -1121,6 +1163,7 @@ function parseOnboardArguments(args) { async function onboard({ repository, runtime, + model, approved, authenticateBrowser, startDashboard @@ -1131,6 +1174,9 @@ async function onboard({ console.log("FixLab onboarding"); console.log(` repository: ${repository}`); console.log(` runtime: ${runtime}`); + if (model) { + console.log(` model: ${model}`); + } const initResult = init(repository); if (initResult !== 0) { @@ -1193,13 +1239,23 @@ async function onboard({ return doctorResult; } if (startDashboard) { - return dashboard(repository, DEFAULT_DASHBOARD_PORT, true, runtime); + return dashboard( + repository, + DEFAULT_DASHBOARD_PORT, + true, + runtime, + model + ); } console.log(""); console.log("FixLab onboarding is ready."); console.log("Start the dashboard with:"); - console.log(` fixlab dashboard "${repository}" --runtime ${runtime}`); + console.log( + ` fixlab dashboard "${repository}" --runtime ${runtime}${ + model ? ` --model ${model}` : "" + }` + ); return 0; } @@ -1227,7 +1283,7 @@ function openBrowser(url) { child.unref(); } -async function dashboard(repository, port, shouldOpen, runtime) { +async function dashboard(repository, port, shouldOpen, runtime, model = "") { const readiness = inspectRepository(repository); if (!readiness.repositoryReady) { console.error(`Cannot start FixLab dashboard: ${readiness.error}`); @@ -1236,7 +1292,8 @@ async function dashboard(repository, port, shouldOpen, runtime) { const dashboardServer = createDashboardServer({ repository, packageRoot, - runtime + runtime, + model }); let address; try { @@ -1249,6 +1306,9 @@ async function dashboard(repository, port, shouldOpen, runtime) { console.log(`FixLab dashboard: ${address.url}`); console.log(`Repository: ${repository}`); console.log(`Runtime: ${runtime}`); + if (model) { + console.log(`Model: ${model}`); + } console.log("Press Ctrl+C to stop the local dashboard."); if (shouldOpen) { openBrowser(address.url); @@ -1370,6 +1430,7 @@ async function startDashboardForChat({ repositoryArgument, port, runtime, + model, baseUrl }) { let repository; @@ -1396,7 +1457,7 @@ async function startDashboardForChat({ console.log( "The repository is not dashboard-ready. Opening the interactive FixLab onboarding session first." ); - const onboardingResult = launch(repository, "", runtime); + const onboardingResult = launch(repository, "", runtime, model); if (onboardingResult !== 0) { throw new Error( `FixLab onboarding exited with code ${onboardingResult}` @@ -1421,7 +1482,8 @@ async function startDashboardForChat({ String(port), "--no-open", "--runtime", - runtime + runtime, + ...(model ? ["--model", model] : []) ], { detached: true, @@ -1503,6 +1565,7 @@ async function main(args) { runArguments.repository, runArguments.request, parsed.runtime, + parsed.model, runArguments.targetEnvironment ); } @@ -1521,7 +1584,8 @@ async function main(args) { return launch( parsed.repository, `Validate pull request ${parsed.pullRequest} without modifying source code or the pull request.`, - runtimeArguments.runtime + runtimeArguments.runtime, + runtimeArguments.model ); } @@ -1540,7 +1604,8 @@ async function main(args) { parsed.repository, parsed.port, parsed.open, - runtimeArguments.runtime + runtimeArguments.runtime, + runtimeArguments.model ); } @@ -1582,16 +1647,19 @@ async function main(args) { repositoryArgument: repository, port: alternatePort, runtime: runtimeArguments.runtime, + model: runtimeArguments.model, baseUrl: alternateUrl }); }; return runChat({ baseUrl, + model: runtimeArguments.model, recoverConnection: () => startDashboardForChat({ repositoryArgument: parsed.repositoryArgument, port: parsed.port, runtime: runtimeArguments.runtime, + model: runtimeArguments.model, baseUrl }), selectRepository: async ({ readiness }) => { @@ -1633,14 +1701,15 @@ async function main(args) { } if (parsed.args.length !== 1) { console.error( - "interactive FixLab accepts one repository path and an optional --runtime" + "interactive FixLab accepts one repository path and optional --runtime and --model values" ); return 1; } return launch( resolveRepository(parsed.args[0]), "", - parsed.runtime + parsed.runtime, + parsed.model ); } diff --git a/dashboard/chat.js b/dashboard/chat.js index 2a21c2b..2bc2c4d 100644 --- a/dashboard/chat.js +++ b/dashboard/chat.js @@ -147,6 +147,7 @@ async function requestJson(fetchImpl, url, options) { export async function runChat({ baseUrl, + model = "", input = process.stdin, output = process.stdout, errorOutput = process.stderr, @@ -253,6 +254,7 @@ export async function runChat({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request: details, + ...(model ? { model } : {}), mode: "fix-and-validate", requestType: "bug-fix", pullRequestStrategy: "common", diff --git a/dashboard/public/app.js b/dashboard/public/app.js index 16b5010..ec0dbf1 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -42,6 +42,8 @@ const screenshotMessage = document.querySelector("#screenshot-message"); const targetEnvironmentSelect = document.querySelector( "#target-environment" ); +const modelControl = document.querySelector("#model-control"); +const modelInput = document.querySelector("#model"); const jobInputPanel = document.querySelector("#job-input-panel"); const jobInputTitle = document.querySelector("#job-input-title"); const jobInputCount = document.querySelector("#job-input-count"); @@ -196,6 +198,21 @@ function renderReadiness(readiness) { } } +function renderRuntime(runtime) { + const supportsModelSelection = runtime?.supportsModelSelection === true; + modelControl.hidden = !supportsModelSelection; + modelInput.disabled = !supportsModelSelection; + if (!supportsModelSelection) { + modelInput.value = ""; + delete modelInput.dataset.initialized; + return; + } + if (!modelInput.dataset.initialized) { + modelInput.value = ""; + modelInput.dataset.initialized = "true"; + } +} + function labelTestEvidence(value) { return String(value ?? "profile-defined") .split("-") @@ -257,10 +274,11 @@ function renderJob(job, currentActiveJob = job) { running && job?.lastActivityAt ? Date.now() - Date.parse(job.lastActivityAt) : null; + const modelSummary = job?.model ? ` · ${job.model}` : ""; jobStatusElement.textContent = job ? `${job.status} · ${job.requestType} · ${ job.targetEnvironment || "profile default" - } · ${formatDuration(job.durationMs)}${ + }${modelSummary} · ${formatDuration(job.durationMs)}${ lastActivityAge === null ? "" : ` · last output ${formatDuration(lastActivityAge)} ago` @@ -668,7 +686,9 @@ function renderQueue(currentJob, queue = [], history = []) { button.classList.add("selected"); } const summary = document.createElement("strong"); - summary.textContent = `${label} · ${job.status} · ${job.requestType}`; + summary.textContent = `${label} · ${job.status} · ${job.requestType}${ + job.model ? ` · ${job.model}` : "" + }`; const details = document.createElement("span"); details.className = "job-request"; details.textContent = @@ -1086,6 +1106,7 @@ async function refresh() { selectedJobId = activeJob?.id ?? null; } renderReadiness(body.readiness); + renderRuntime(body.runtime); renderJob(observedJobs.get(selectedJobId) ?? activeJob, activeJob); renderQueue(activeJob, body.queue, body.history); } catch (error) { @@ -1125,6 +1146,7 @@ form.addEventListener("submit", async (event) => { body: JSON.stringify({ request: data.get("request"), requestType: data.get("requestType"), + model: data.get("model"), mode: data.get("mode"), pullRequestStrategy: data.get("separatePullRequests") === "on" ? "per-bug" diff --git a/dashboard/public/index.html b/dashboard/public/index.html index 075f6d1..6287abc 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -91,6 +91,14 @@

Repository onboarding

+