diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index fad9c65443e..4d8d82f5fc4 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -25,8 +25,8 @@ export class ModelRegistry { } /** Reload models.json asynchronously. Await before making synchronous registry reads. */ - refresh(): Promise { - return this.runtime.reloadConfig(); + async refresh(): Promise { + await this.runtime.refresh(); } getError(): string | undefined { diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 6b963913578..e4575b07f10 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -513,14 +513,10 @@ export class ModelRuntime implements Models { await this.refresh({ allowNetwork: this.modelNetworkEnabled }); } - async reloadConfig(): Promise { + async refresh(options: ModelsRefreshOptions = {}): Promise { this.config = await ModelConfig.load(this.modelsPath); this.configureRadiusProviders(); this.rebuildProviders(); - await this.refresh({ allowNetwork: this.modelNetworkEnabled }); - } - - async refresh(options: ModelsRefreshOptions = {}): Promise { const refreshOptions = { ...options, allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled, diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 5f003241e81..a1dd2ce2660 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -1,9 +1,9 @@ -import { createInMemoryModelRegistry, getModelRuntime } from "../model-runtime-test-utils.ts"; +import { createInMemoryModelRegistry, createModelRegistry, getModelRuntime } from "../model-runtime-test-utils.ts"; /** * Local test harness for the new coding-agent test suite. */ -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; @@ -71,6 +71,7 @@ export interface HarnessOptions { resourceLoader?: ResourceLoader; extensionFactories?: Array; withConfiguredAuth?: boolean; + modelsJson?: Record; } export interface Harness { @@ -115,7 +116,11 @@ export async function createHarness(options: HarnessOptions = {}): Promise ({ type: "api_key", key: "faux-key" })); } - const modelRegistry = await createInMemoryModelRegistry(authStorage); + const modelsPath = options.modelsJson === undefined ? undefined : join(tempDir, "models.json"); + if (modelsPath) writeFileSync(modelsPath, JSON.stringify(options.modelsJson)); + const modelRegistry = modelsPath + ? await createModelRegistry(authStorage, modelsPath) + : await createInMemoryModelRegistry(authStorage); if (withConfiguredAuth) { modelRegistry.registerProvider(model.provider, { baseUrl: model.baseUrl, diff --git a/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts new file mode 100644 index 00000000000..9e3c7c8421a --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts @@ -0,0 +1,68 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../../../src/core/keybindings.ts"; +import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../../../src/utils/ansi.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +function createFakeTui(): TUI { + return { + requestRender: () => {}, + } as unknown as TUI; +} + +function modelsJson(provider: string, model: string): Record { + return { + providers: { + [provider]: { + baseUrl: "https://example.test/v1", + api: "openai-completions", + apiKey: "test-key", + models: [{ id: model }], + }, + }, + }; +} + +describe("issue #6999 models.json hot reload", () => { + let harness: Harness | undefined; + + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + afterEach(() => { + harness?.cleanup(); + harness = undefined; + }); + + it("reloads models.json when opening /model", async () => { + harness = await createHarness({ modelsJson: modelsJson("old-provider", "old-model") }); + expect(harness.session.modelRuntime.getModel("old-provider", "old-model")).toBeDefined(); + + writeFileSync(join(harness.tempDir, "models.json"), JSON.stringify(modelsJson("new-provider", "new-model"))); + const selector = new ModelSelectorComponent( + createFakeTui(), + harness.getModel(), + harness.settingsManager, + harness.session.modelRuntime, + [], + () => {}, + () => {}, + ); + + await vi.waitFor(() => { + const rendered = stripAnsi(selector.render(120).join("\n")); + expect(rendered).toContain("new-model [new-provider]"); + expect(rendered).toContain("Model catalogs refreshed."); + }); + expect(harness.session.modelRuntime.getModel("old-provider", "old-model")).toBeUndefined(); + }); +});