new_pull
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user