This commit is contained in:
2026-07-26 14:02:37 +07:00
parent bc56546b49
commit 367ebc1c7f
171 changed files with 4617 additions and 10402 deletions
@@ -0,0 +1,44 @@
import { Text } from "@earendil-works/pi-tui";
import { describe, expect, test } from "vitest";
import type { MessageRenderer, MessageRenderOptions } from "../src/core/extensions/types.ts";
import type { CustomMessage } from "../src/core/messages.ts";
import { CustomMessageComponent } from "../src/modes/interactive/components/custom-message.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
describe("CustomMessageComponent", () => {
test("provides output padding to custom renderers and updates it", () => {
initTheme("dark");
const optionsSeen: MessageRenderOptions[] = [];
const renderer: MessageRenderer = (_message, options) => {
optionsSeen.push(options);
return new Text("custom", options.outputPad, 0);
};
const message: CustomMessage = {
role: "custom",
customType: "test",
content: "custom",
display: true,
timestamp: Date.now(),
};
const component = new CustomMessageComponent(message, renderer, undefined, 1);
expect(optionsSeen).toEqual([{ expanded: false, outputPad: 1 }]);
expect(
component
.render(40)
.map(stripAnsi)
.some((line) => line.startsWith(" custom")),
).toBe(true);
component.setOutputPad(0);
expect(optionsSeen.at(-1)).toEqual({ expanded: false, outputPad: 0 });
expect(
component
.render(40)
.map(stripAnsi)
.some((line) => line.startsWith("custom")),
).toBe(true);
});
});
@@ -1,7 +1,7 @@
import { once } from "node:events";
import { createServer, type RequestListener, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import type { AuthContext, AuthPrompt } from "@earendil-works/pi-ai";
import type { AuthContext, AuthPrompt, ModelsStoreEntry } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { createEventBus } from "../src/core/event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.ts";
@@ -67,7 +67,7 @@ describe("llama.cpp extension", () => {
id: "loaded",
status: { value: "loaded", args: ["llama-server", "--n-gpu-layers", "999"] },
architecture: { input_modalities: ["text", "image"] },
meta: { n_ctx: 16384, n_ctx_train: 131072 },
meta: { n_ctx: 65536, n_ctx_train: 131072 },
},
{ id: "unloaded", status: { value: "unloaded" } },
{ id: "loading", status: { value: "loading" } },
@@ -79,13 +79,57 @@ describe("llama.cpp extension", () => {
expect.objectContaining({
id: "loaded",
baseUrl: "http://localhost:8080/v1",
contextWindow: 16384,
maxTokens: 16384,
contextWindow: 65536,
maxTokens: 65536,
input: ["text", "image"],
}),
]);
});
it("persists and restores loaded models for cache-only startup refreshes", async () => {
let cachedEntry: ModelsStoreEntry | undefined;
const store = {
read: async () => cachedEntry,
write: async (entry: ModelsStoreEntry) => {
cachedEntry = structuredClone(entry);
},
delete: async () => {
cachedEntry = undefined;
},
};
const { url } = await listen((request, response) => {
if (request.url === "/models") {
json(response, {
data: [
{ id: "loaded", status: { value: "loaded" }, meta: { n_ctx: 32768 } },
{ id: "unloaded", status: { value: "unloaded" } },
],
});
return;
}
response.writeHead(404).end();
});
const first = createLlamaProvider();
await first.provider.refreshModels?.({
credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } },
store,
allowNetwork: true,
});
expect(first.provider.getModels().map((model) => model.id)).toEqual(["loaded"]);
expect(cachedEntry?.models.map((model) => model.id)).toEqual(["loaded"]);
const second = createLlamaProvider();
await second.provider.refreshModels?.({
credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } },
store,
allowNetwork: false,
});
expect(second.provider.getModels()).toEqual([
expect.objectContaining({ id: "loaded", baseUrl: `${url}/v1`, contextWindow: 32768 }),
]);
});
it("stays dormant until configured and stores URL plus optional key", async () => {
const { provider } = createLlamaProvider();
const auth = provider.auth.apiKey!;
@@ -225,11 +225,13 @@ describe("resolveModelScopeWithDiagnostics", () => {
{
type: "warning",
message: 'Invalid thinking level "invalid" in pattern "gpt-4o:invalid". Using default instead.',
code: "invalid-thinking-level",
pattern: "gpt-4o:invalid",
},
{
type: "warning",
message: 'No models match pattern "missing"',
code: "no-match",
pattern: "missing",
},
]);
@@ -255,6 +257,53 @@ describe("resolveModelScopeWithDiagnostics", () => {
warn.mockRestore();
}
});
test("resolves bracketed model ids as exact references before glob matching", async () => {
const bracketedModel: Model<"anthropic-messages"> = {
id: "bracketed-model[1m]",
name: "Bracketed Model",
api: "anthropic-messages",
provider: "custom",
baseUrl: "https://example.invalid",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const registry = {
getAvailable: () => [...allModels, bracketedModel],
} as unknown as Parameters<typeof resolveModelScopeWithDiagnostics>[1];
const result = await resolveModelScopeWithDiagnostics(["custom/bracketed-model[1m]"], registry);
expect(result.scopedModels.map((scoped) => scoped.model.id)).toEqual(["bracketed-model[1m]"]);
expect(result.diagnostics).toEqual([]);
});
test("resolves bracketed model ids with thinking levels as exact references before glob matching", async () => {
const bracketedModel: Model<"anthropic-messages"> = {
id: "bracketed-model[1m]",
name: "Bracketed Model",
api: "anthropic-messages",
provider: "custom",
baseUrl: "https://example.invalid",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const registry = {
getAvailable: () => [...allModels, bracketedModel],
} as unknown as Parameters<typeof resolveModelScopeWithDiagnostics>[1];
const result = await resolveModelScopeWithDiagnostics(["custom/bracketed-model[1m]:high"], registry);
expect(result.scopedModels.map((scoped) => scoped.model.id)).toEqual(["bracketed-model[1m]"]);
expect(result.scopedModels[0].thinkingLevel).toBe("high");
expect(result.diagnostics).toEqual([]);
});
});
describe("resolveCliModel", () => {
@@ -1,4 +1,3 @@
import { statSync } from "node:fs";
import {
createProvider,
InMemoryModelsStore,
@@ -25,7 +24,7 @@ function model(id: string): Model<"openai-completions"> {
};
}
function testProvider(localCatalogUrl?: URL) {
function testProvider(localGeneratedAt?: number) {
return withRemoteCatalog(
createProvider({
id: "test-provider",
@@ -41,7 +40,7 @@ function testProvider(localCatalogUrl?: URL) {
},
}),
"https://pi.dev",
localCatalogUrl,
localGeneratedAt,
);
}
@@ -80,19 +79,18 @@ describe("remote catalog provider", () => {
});
it("prefers the newer of the generated and remote catalogs", async () => {
const localCatalogUrl = new URL(import.meta.url);
const localMtime = statSync(localCatalogUrl).mtimeMs;
const newerHeader = new Date(localMtime + 60_000).toUTCString();
const localGeneratedAt = Date.parse("2026-07-23T10:00:00.000Z");
const newerHeader = new Date(localGeneratedAt + 60_000).toUTCString();
const responses = [
new Response(JSON.stringify({ old: model("old") }), {
headers: { "last-modified": new Date(localMtime - 60_000).toUTCString() },
headers: { "last-modified": new Date(localGeneratedAt - 60_000).toUTCString() },
}),
new Response(JSON.stringify({ newer: model("newer") }), {
headers: { "last-modified": newerHeader },
}),
];
vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider(localCatalogUrl);
const provider = testProvider(localGeneratedAt);
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
@@ -104,6 +102,76 @@ describe("remote catalog provider", () => {
expect(await store.read(provider.id)).toMatchObject({ lastModified: Date.parse(newerHeader) });
});
it("revalidates a stored catalog with its etag and keeps the overlay on 304", async () => {
const responses = [
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
headers: { "content-type": "application/json", etag: '"catalog-1"' },
}),
new Response(null, { status: 304, headers: { etag: '"catalog-1"' } }),
];
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider();
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
expect(fetchSpy.mock.calls[0]?.[1]?.headers).not.toHaveProperty("if-none-match");
expect(await store.read(provider.id)).toMatchObject({ etag: '"catalog-1"' });
const checkedAt = (await store.read(provider.id))?.checkedAt;
await provider.refreshModels?.({ ...refresh, force: true });
expect(fetchSpy.mock.calls[1]?.[1]?.headers).toMatchObject({ "if-none-match": '"catalog-1"' });
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
const stored = await store.read(provider.id);
expect(stored?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
expect(stored?.etag).toBe('"catalog-1"');
expect(stored?.checkedAt).toBeGreaterThanOrEqual(checkedAt ?? 0);
});
it("drops a stale etag when the overlay becomes unavailable", async () => {
const responses = [
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
headers: { "content-type": "application/json", etag: '"catalog-1"' },
}),
new Response("not implemented", { status: 501 }),
];
vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider();
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
await provider.refreshModels?.({ ...refresh, force: true });
expect((await store.read(provider.id))?.etag).toBeUndefined();
});
it("keeps the etag and overlay after a transient failure", async () => {
const responses = [
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
headers: { "content-type": "application/json", etag: '"catalog-1"' },
}),
new Response("rate limited", { status: 429 }),
new Response(null, { status: 304, headers: { etag: '"catalog-1"' } }),
];
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider();
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
await expect(provider.refreshModels?.({ ...refresh, force: true })).rejects.toThrow(/429/);
const stored = await store.read(provider.id);
expect(stored?.etag).toBe('"catalog-1"');
expect(stored?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
await provider.refreshModels?.({ ...refresh, force: true });
expect(fetchSpy.mock.calls[2]?.[1]?.headers).toMatchObject({ "if-none-match": '"catalog-1"' });
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
});
it("treats unimplemented pi.dev catalog routes as an unavailable overlay", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not implemented", { status: 501 }));
const provider = testProvider();
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
import { DefaultResourceLoader } from "../src/core/resource-loader.ts";
@@ -355,6 +355,22 @@ Content`,
expect(agentsFiles.some((f) => f.path.includes("AGENTS.md"))).toBe(true);
});
it("should ignore context file candidates that are directories", async () => {
mkdirSync(join(cwd, "AGENTS.md"));
writeFileSync(join(cwd, "CLAUDE.md"), "Fallback instructions");
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
expect(loader.getAgentsFiles().agentsFiles).toContainEqual({
path: join(cwd, "CLAUDE.md"),
content: "Fallback instructions",
});
expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining(join(cwd, "AGENTS.md")));
consoleError.mockRestore();
});
it("should skip AGENTS.md and CLAUDE.md discovery when noContextFiles is true", async () => {
writeFileSync(join(cwd, "AGENTS.md"), "# Project Guidelines\n\nBe helpful.");
writeFileSync(join(cwd, "CLAUDE.md"), "# Claude Guidelines\n\nBe helpful.");
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { type Settings, SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
@@ -76,7 +76,7 @@ describe("createAgentSession stream options", () => {
async function captureStreamOptions(
api: Api,
settings: { httpIdleTimeoutMs?: number; websocketConnectTimeoutMs?: number },
settings: Partial<Settings>,
requestOptions: SimpleStreamOptions = {},
extensionSource?: string,
): Promise<SimpleStreamOptions | undefined> {
@@ -161,6 +161,15 @@ describe("createAgentSession stream options", () => {
expect(options?.websocketConnectTimeoutMs).toBe(0);
});
it("forwards provider retry settings", async () => {
const options = await captureStreamOptions("openai-completions", {
retry: { provider: { maxRetries: 2, maxRetryDelayMs: 3000 } },
});
expect(options?.maxRetries).toBe(2);
expect(options?.maxRetryDelayMs).toBe(3000);
});
it("runs before_provider_headers on assembled headers without forwarding the transform", async () => {
const options = await captureStreamOptions(
"openai-completions",
@@ -239,4 +239,35 @@ describe("AgentSession bash and persistence characterization", () => {
expect(result.output).toContain("hello from custom ops");
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
});
it("streams bash output to the callback and session events", async () => {
const harness = await createHarness();
harnesses.push(harness);
const callbackDeltas: string[] = [];
const eventUpdates: Array<{ id: string | undefined; delta: string }> = [];
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "bash_execution_update") {
eventUpdates.push({ id: event.id, delta: event.delta });
}
});
const operations: BashOperations = {
exec: async (_command, _cwd, options) => {
options.onData(Buffer.from("hello "));
options.onData(Buffer.from("world"));
return { exitCode: 0 };
},
};
await harness.session.executeBash("custom", (delta) => callbackDeltas.push(delta), {
id: "bash-1",
operations,
});
unsubscribe();
expect(callbackDeltas).toEqual(["hello ", "world"]);
expect(eventUpdates).toEqual([
{ id: "bash-1", delta: "hello " },
{ id: "bash-1", delta: "world" },
]);
});
});
@@ -175,6 +175,41 @@ describe("AgentSession compaction characterization", () => {
expect(getStreamCallCount()).toBe(1);
});
it("manually compacts with provider-resolved bearer auth", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
const model = harness.getModel();
harness.session.modelRuntime.registerNativeProvider({
id: model.provider,
name: "Faux bearer provider",
auth: {
apiKey: {
name: "Faux bearer token",
resolve: async () => ({
auth: { headers: { Authorization: "Bearer ambient-token" } },
source: "ambient bearer token",
}),
},
},
getModels: () => harness.models,
stream: () => createAssistantMessageEventStream(),
streamSimple: () => createAssistantMessageEventStream(),
});
seedCompactableSession(harness);
harness.setResponses([
(_context, options) => {
expect(options?.apiKey).toBeUndefined();
expect(options?.headers).toEqual({ Authorization: "Bearer ambient-token" });
return fauxAssistantMessage("summary with bearer auth");
},
]);
const result = await harness.session.compact();
expect(result.summary).toContain("summary with bearer auth");
expect(harness.faux.state.callCount).toBe(1);
});
it("persists usage from pi-generated manual compaction", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
+8 -3
View File
@@ -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<InlineExtension | CreateTestExtensionsResultInput>;
withConfiguredAuth?: boolean;
modelsJson?: Record<string, unknown>;
}
export interface Harness {
@@ -115,7 +116,11 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
if (withConfiguredAuth) {
await authStorage.modify(model.provider, async () => ({ 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,
@@ -0,0 +1,160 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import { setKeybindings } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.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 createInteractiveContext(options: {
allModels: Model<Api>[];
enabledModelIds: string[];
scopedModels?: Array<{ model: Model<Api> }>;
}) {
let selector: ScopedModelsSelectorComponent | undefined;
const setScopedModels = vi.fn();
const getAvailable = vi.fn().mockResolvedValue(options.allModels);
const context = {
session: {
modelRuntime: {
refresh: vi.fn(),
getAvailable,
},
scopedModels: options.scopedModels ?? [],
setScopedModels,
},
settingsManager: {
getEnabledModels: () => options.enabledModelIds,
setEnabledModels: vi.fn(),
},
showStatus: vi.fn(),
showSelector: (factory: (done: () => void) => { component: ScopedModelsSelectorComponent }) => {
selector = factory(() => {}).component;
},
updateAvailableProviderCount: vi.fn(),
ui: { requestRender: vi.fn() },
};
return { context, getAvailable, getSelector: () => selector, setScopedModels };
}
async function showModelsSelector(context: object): Promise<void> {
const show = Reflect.get(InteractiveMode.prototype, "showModelsSelector") as (this: object) => Promise<void>;
await show.call(context);
}
describe("issue #6949 unavailable scoped models", () => {
const harnesses: Harness[] = [];
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
afterEach(() => {
while (harnesses.length > 0) harnesses.pop()?.cleanup();
});
it("shows and removes an enabled model without a catalog entry", async () => {
const harness = await createHarness({ models: [{ id: "available", name: "Available" }] });
harnesses.push(harness);
const availableId = `${harness.models[0].provider}/${harness.models[0].id}`;
const unavailableId = `${harness.models[0].provider}/unavailable`;
const changes: Array<string[] | null> = [];
const persisted: Array<string[] | null> = [];
const selector = new ScopedModelsSelectorComponent(
{
allModels: [...harness.models],
enabledModelIds: [unavailableId, availableId],
},
{
onChange: (enabledIds) => {
changes.push(enabledIds);
},
onPersist: (enabledIds) => {
persisted.push(enabledIds);
},
onCancel: () => {},
},
);
expect(stripAnsi(selector.render(100).join("\n"))).toContain(`${unavailableId} [unavailable] ✗`);
selector.handleInput("\r");
expect(changes).toEqual([[availableId]]);
selector.handleInput("\x13");
expect(persisted).toEqual([[availableId]]);
});
it("passes unmatched settings patterns to the selector with one combined resolution", async () => {
const harness = await createHarness({ models: [{ id: "available", name: "Available" }] });
harnesses.push(harness);
const unavailableIds = ["unavailable-one", "unavailable-two"].map((id) => `${harness.models[0].provider}/${id}`);
const { context, getAvailable, getSelector } = createInteractiveContext({
allModels: [],
enabledModelIds: unavailableIds,
});
await showModelsSelector(context);
const selector = getSelector();
if (!selector) throw new Error("Expected scoped-model selector to open");
const rendered = stripAnsi(selector.render(100).join("\n"));
for (const unavailableId of unavailableIds) {
expect(rendered).toContain(`${unavailableId} [unavailable] ✗`);
}
expect(getAvailable).toHaveBeenCalledTimes(2);
});
it("opens when only a session-scoped model is unavailable", async () => {
const harness = await createHarness({ models: [{ id: "unavailable", name: "Unavailable" }] });
harnesses.push(harness);
const model = harness.models[0];
const fullId = `${model.provider}/${model.id}`;
const { context, getSelector } = createInteractiveContext({
allModels: [],
enabledModelIds: [],
scopedModels: [{ model }],
});
await showModelsSelector(context);
const selector = getSelector();
if (!selector) throw new Error("Expected scoped-model selector to open");
expect(stripAnsi(selector.render(100).join("\n"))).toContain(`${fullId} [unavailable] ✗`);
});
it("does not clear a partial scope when an enabled model is unavailable", async () => {
const harness = await createHarness({
models: [
{ id: "one", name: "One" },
{ id: "two", name: "Two" },
{ id: "three", name: "Three" },
],
});
harnesses.push(harness);
const [one, two] = harness.models;
const enabledIds = [one, two].map((model) => `${model.provider}/${model.id}`);
const unavailableId = `${one.provider}/unavailable`;
const { context, getSelector, setScopedModels } = createInteractiveContext({
allModels: [...harness.models],
enabledModelIds: [...enabledIds, unavailableId],
scopedModels: [{ model: one }, { model: two }],
});
await showModelsSelector(context);
const selector = getSelector();
if (!selector) throw new Error("Expected scoped-model selector to open");
selector.handleInput("\x1b[1;3B");
await vi.waitFor(() => {
expect(setScopedModels).toHaveBeenLastCalledWith([
{ model: two, thinkingLevel: undefined },
{ model: one, thinkingLevel: undefined },
]);
});
});
});
@@ -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<string, unknown> {
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();
});
});