feat(FN-3227): add startup model sync for opencode-go commands

- Add shared startup model sync helper and integrate it into dashboard, serve, and daemon command startup flows
- Introduce opencode-go model sync setting in core schema/types and expose it in dashboard settings UI
- Add CLI regression tests covering startup model sync behavior and command wiring
- Document the new startup model sync setting and related CLI behavior updates

Fusion-Task-Id: FN-3227
This commit is contained in:
Fusion
2026-05-04 16:25:47 -07:00
committed by gsxdsm
parent ef4f3ba3be
commit d5e8318748
14 changed files with 475 additions and 126 deletions

View File

@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
const mocks = vi.hoisted(() => {
type ListenCall = {
port: number;
@@ -584,6 +592,11 @@ vi.mock("../task-lifecycle.js", () => ({
const { runDaemon } = await import("../daemon.js");
describe("runDaemon", () => {
it("invokes shared startup model sync", async () => {
const { runDaemon } = await import("../daemon.js");
await runDaemon({});
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
});
const originalCwd = process.cwd;
const originalExit = process.exit;

View File

@@ -2,6 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
const CLI_PACKAGE_VERSION = (
JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf-8")) as { version: string }
).version;
@@ -735,6 +743,13 @@ async function runDashboard(...args: Parameters<typeof runDashboardImpl>): Retur
// ── Tests ───────────────────────────────────────────────────────────
describe("runDashboard — startup model sync", () => {
it("invokes shared startup model sync", async () => {
await runDashboard(0, { open: false });
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
});
});
function resetGitHubMocks() {
mockFindPrForBranch.mockReset();
mockCreatePr.mockReset();

View File

@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
// ── Multi-project test fixtures ─────────────────────────────────────────
//
// Test fixtures model at least two registered projects with distinct IDs/paths
@@ -649,6 +657,11 @@ vi.mock("../task-lifecycle.js", () => ({
const { runServe } = await import("../serve.js");
describe("runServe", () => {
it("invokes shared startup model sync", async () => {
const { runServe } = await import("../serve.js");
await runServe(4040, {});
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
});
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;

View File

@@ -0,0 +1,123 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
import { parseOpencodeModelsOutput, syncStartupModels } from "../startup-model-sync.js";
type MockProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
function createSpawnProcess(): MockProcess {
const proc = new EventEmitter() as MockProcess;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
return proc;
}
describe("startup-model-sync", () => {
beforeEach(() => {
mockSpawn.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("syncs OpenRouter and opencode-go models", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("Models cache refreshed\nopencode/gpt-5\nopencode-go/custom\n"));
proc.emit("exit", 0);
});
return proc;
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
data: [{ id: "openai/gpt-4o", name: "GPT-4o", context_length: 128000 }],
}),
}));
const registerProvider = vi.fn();
const log = vi.fn();
const run = syncStartupModels({
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: true }),
authStorage: { getApiKey: vi.fn().mockResolvedValue("key") },
modelRegistry: { registerProvider },
log,
});
await run;
expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) }));
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: expect.arrayContaining([
expect.objectContaining({ id: "opencode-go/gpt-5" }),
expect.objectContaining({ id: "opencode-go/custom" }),
]),
}));
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced"));
expect(log).toHaveBeenCalledWith("opencode-go", expect.stringContaining("Synced"));
});
it("respects disabled settings", async () => {
vi.stubGlobal("fetch", vi.fn());
const registerProvider = vi.fn();
await syncStartupModels({
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: false, opencodeGoModelSync: false }),
authStorage: { getApiKey: vi.fn() },
modelRegistry: { registerProvider },
log: vi.fn(),
});
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
expect(registerProvider).not.toHaveBeenCalled();
});
it("logs failures and continues", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stderr.emit("data", Buffer.from("provider unavailable"));
proc.emit("exit", 1);
});
return proc;
});
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
const log = vi.fn();
const run = syncStartupModels({
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: true }),
authStorage: { getApiKey: vi.fn().mockResolvedValue(undefined) },
modelRegistry: { registerProvider: vi.fn() },
log,
});
await run;
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Failed to sync models"));
expect(log).toHaveBeenCalledWith("opencode-go", expect.stringContaining("Failed to sync models"));
});
it("parses model ids from opencode CLI output", () => {
expect(parseOpencodeModelsOutput("Models cache refreshed\nopencode/gpt-5\nfoo\nopencode-go/custom\n")).toEqual([
"opencode/gpt-5",
"opencode-go/custom",
]);
});
});