refactor(model-discovery): converted model discovery to synchronous file operations
- Changed loadCustomModels from async to sync using fs.readFileSync for immediate resolution. - Updated discoverModels to return synchronously, removing await from main.ts, pipeline.ts, and commit index.ts. - Migrated sdk.ts to use node:fs instead of node:fs/promises for synchronous operations. - Refactored scanDirectoryForSkills to use recursive traversal instead of batch Promise.all. - Added tarball Dockerfile for testing npm publish/install flow with verdaccio registry.
This commit is contained in:
@@ -458,7 +458,7 @@ describe("Context overflow error handling", () => {
|
||||
|
||||
describe.skipIf(!ollamaInstalled)("Ollama (local)", () => {
|
||||
let ollamaProcess: ChildProcess | null = null;
|
||||
let model: Model<"openai-completions">;
|
||||
let model: Model<"openai-completions"> | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Check if model is available, if not pull it
|
||||
@@ -519,6 +519,7 @@ describe("Context overflow error handling", () => {
|
||||
});
|
||||
|
||||
it("gpt-oss:20b - should detect overflow via isContextOverflow (ollama silently truncates)", async () => {
|
||||
if (!model) return;
|
||||
const result = await testContextOverflow(model, "ollama");
|
||||
logResult(result);
|
||||
|
||||
|
||||
@@ -1360,7 +1360,7 @@ describe("Generate E2E Tests", () => {
|
||||
}
|
||||
|
||||
describe.skipIf(!ollamaInstalled)("Ollama Provider (gpt-oss-20b via OpenAI Completions)", () => {
|
||||
let llm: Model<"openai-completions">;
|
||||
let llm: Model<"openai-completions"> | undefined;
|
||||
let ollamaProcess: ChildProcess | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -1430,6 +1430,7 @@ describe("Generate E2E Tests", () => {
|
||||
it(
|
||||
"should complete basic text generation",
|
||||
async () => {
|
||||
if (!llm) return;
|
||||
await basicTextGeneration(llm, { apiKey: "test" });
|
||||
},
|
||||
{ retry: 3 },
|
||||
@@ -1438,6 +1439,7 @@ describe("Generate E2E Tests", () => {
|
||||
it(
|
||||
"should handle tool calling",
|
||||
async () => {
|
||||
if (!llm) return;
|
||||
await handleToolCall(llm, { apiKey: "test" });
|
||||
},
|
||||
{ retry: 3 },
|
||||
@@ -1446,6 +1448,7 @@ describe("Generate E2E Tests", () => {
|
||||
it(
|
||||
"should handle streaming",
|
||||
async () => {
|
||||
if (!llm) return;
|
||||
await handleStreaming(llm, { apiKey: "test" });
|
||||
},
|
||||
{ retry: 3 },
|
||||
@@ -1454,6 +1457,7 @@ describe("Generate E2E Tests", () => {
|
||||
it(
|
||||
"should handle thinking mode",
|
||||
async () => {
|
||||
if (!llm) return;
|
||||
await handleThinking(llm, { apiKey: "test", reasoningEffort: "medium" });
|
||||
},
|
||||
{ retry: 3 },
|
||||
@@ -1462,6 +1466,7 @@ describe("Generate E2E Tests", () => {
|
||||
it(
|
||||
"should handle multi-turn with thinking and tools",
|
||||
async () => {
|
||||
if (!llm) return;
|
||||
await multiTurn(llm, { apiKey: "test", reasoningEffort: "medium" });
|
||||
},
|
||||
{ retry: 3 },
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
- Added color manipulation utilities (hexToHsv, hsvToHex, shiftHue) to pi-utils for accessible theme adjustments
|
||||
- Added color-blind mode setting for improved accessibility
|
||||
- Added filesystem error type guards (isEnoent, isEacces, isPerm, isEnotempty, isFsError, hasFsCode) to pi-utils for safe error handling
|
||||
- Added tarball installation test Dockerfile to validate npm publish/install flow
|
||||
|
||||
### Changed
|
||||
- Changed changelog diff truncation limit to be configurable via settings
|
||||
@@ -109,6 +110,7 @@
|
||||
- Improved error logging in settings manager for config file access failures
|
||||
- Migrated node module imports from named to namespace imports across all packages for consistency with project guidelines
|
||||
- Improved filesystem error handling in extension loader with additional type guards (isEacces, hasFsCode) for permission and EPERM errors
|
||||
- Changed model discovery to synchronous file operations for more immediate initialization
|
||||
|
||||
### Fixed
|
||||
- Fixed database busy errors during concurrent access by adding retry logic with exponential backoff when opening storage
|
||||
|
||||
@@ -27,9 +27,9 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const git = new ControlledGit(cwd);
|
||||
const [settingsManager, authStorage] = await Promise.all([SettingsManager.create(cwd), discoverAuthStorage()]);
|
||||
const modelRegistryPromise = discoverModels(authStorage);
|
||||
|
||||
writeStdout("● Resolving model...");
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
const stagedFilesPromise = (async () => {
|
||||
let stagedFiles = await git.getStagedFiles();
|
||||
if (stagedFiles.length === 0) {
|
||||
@@ -40,7 +40,6 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
|
||||
return stagedFiles;
|
||||
})();
|
||||
|
||||
const modelRegistry = await modelRegistryPromise;
|
||||
const primaryModelPromise = resolvePrimaryModel(args.model, settingsManager, modelRegistry);
|
||||
const [primaryModelResult, stagedFiles] = await Promise.all([primaryModelPromise, stagedFilesPromise]);
|
||||
const { model: primaryModel, apiKey: primaryApiKey } = primaryModelResult;
|
||||
|
||||
@@ -41,7 +41,7 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise<void> {
|
||||
const settingsManager = await SettingsManager.create(cwd);
|
||||
const commitSettings = settingsManager.getCommitSettings();
|
||||
const authStorage = await discoverAuthStorage();
|
||||
const modelRegistry = await discoverModels(authStorage);
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
|
||||
const { model: primaryModel, apiKey: primaryApiKey } = await resolvePrimaryModel(
|
||||
args.model,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Model registry - manages built-in and custom models, provides API key resolution.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import {
|
||||
type Api,
|
||||
getGitHubCopilotBaseUrl,
|
||||
@@ -149,6 +149,8 @@ export class ModelRegistry {
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
// Load models synchronously in constructor
|
||||
this.loadModels();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,10 +193,10 @@ export class ModelRegistry {
|
||||
/**
|
||||
* Reload models from disk (built-in + custom from models.json).
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
refresh(): void {
|
||||
this.customProviderApiKeys.clear();
|
||||
this.loadError = undefined;
|
||||
await this.loadModels();
|
||||
this.loadModels();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +206,7 @@ export class ModelRegistry {
|
||||
return this.loadError;
|
||||
}
|
||||
|
||||
private async loadModels(): Promise<void> {
|
||||
private loadModels() {
|
||||
// Load custom models from models.json first (to know which providers to skip/override)
|
||||
let customModels: Model<Api>[] = [];
|
||||
let replacedProviders: Set<string> = new Set();
|
||||
@@ -216,7 +218,7 @@ export class ModelRegistry {
|
||||
}
|
||||
|
||||
for (const modelsPath of pathsToCheck) {
|
||||
const result = await this.loadCustomModels(modelsPath);
|
||||
const result = this.loadCustomModels(modelsPath);
|
||||
if (!result.found) {
|
||||
continue; // File doesn't exist, try next path
|
||||
}
|
||||
@@ -266,10 +268,10 @@ export class ModelRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
private async loadCustomModels(modelsPath: string): Promise<CustomModelsResult> {
|
||||
private loadCustomModels(modelsPath: string): CustomModelsResult {
|
||||
let content: string;
|
||||
try {
|
||||
content = await Bun.file(modelsPath).text();
|
||||
content = fs.readFileSync(modelsPath, "utf-8");
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) {
|
||||
return emptyCustomModelsResult();
|
||||
|
||||
@@ -72,34 +72,30 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
|
||||
|
||||
async function scanDir(dir: string): Promise<void> {
|
||||
try {
|
||||
// First check if this directory itself is a skill
|
||||
const selfSkillFile = path.join(dir, "SKILL.md");
|
||||
try {
|
||||
const s = await fs.stat(selfSkillFile);
|
||||
if (s.isFile()) {
|
||||
await addSkill(selfSkillFile, dir, path.basename(dir));
|
||||
// This directory is a skill, don't recurse
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// No SKILL.md in this directory
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const skillFile = path.join(fullPath, "SKILL.md");
|
||||
tasks.push(
|
||||
fs
|
||||
.stat(skillFile)
|
||||
.then((s) => {
|
||||
if (s.isFile()) {
|
||||
return addSkill(skillFile, fullPath, entry.name);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// No SKILL.md in this directory
|
||||
}),
|
||||
);
|
||||
tasks.push(scanDir(fullPath));
|
||||
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
||||
tasks.push(addSkill(fullPath, dir, path.basename(dir)));
|
||||
await scanDir(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
} catch (err) {
|
||||
warnings.push({ skillPath: dir, message: `Failed to read directory: ${err}` });
|
||||
}
|
||||
@@ -144,34 +140,30 @@ async function scanDirectoryForSkills(dir: string): Promise<LoadSkillsResult> {
|
||||
|
||||
async function scanDir(currentDir: string): Promise<void> {
|
||||
try {
|
||||
// First check if this directory itself is a skill
|
||||
const selfSkillFile = path.join(currentDir, "SKILL.md");
|
||||
try {
|
||||
const s = await fs.stat(selfSkillFile);
|
||||
if (s.isFile()) {
|
||||
await addSkill(selfSkillFile, currentDir, path.basename(currentDir));
|
||||
// This directory is a skill, don't recurse
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// No SKILL.md in this directory
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const skillFile = path.join(fullPath, "SKILL.md");
|
||||
tasks.push(
|
||||
fs
|
||||
.stat(skillFile)
|
||||
.then((s) => {
|
||||
if (s.isFile()) {
|
||||
return addSkill(skillFile, fullPath, entry.name);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// No SKILL.md in this directory
|
||||
}),
|
||||
);
|
||||
tasks.push(scanDir(fullPath));
|
||||
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
||||
tasks.push(addSkill(fullPath, currentDir, path.basename(currentDir)));
|
||||
await scanDir(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
} catch (err) {
|
||||
warnings.push({ skillPath: currentDir, message: `Failed to read directory: ${err}` });
|
||||
}
|
||||
|
||||
@@ -571,7 +571,7 @@ export async function main(args: string[]) {
|
||||
|
||||
// Create AuthStorage and ModelRegistry upfront
|
||||
const authStorage = await discoverAuthStorage();
|
||||
const modelRegistry = await discoverModels(authStorage);
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
time("discoverModels");
|
||||
|
||||
if (parsed.version) {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { Agent, type AgentEvent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { type Message, type Model, supportsXhigh } from "@oh-my-pi/pi-ai";
|
||||
@@ -274,20 +274,19 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir(
|
||||
* Prefers models.yml over models.json. Reads from primary path first,
|
||||
* then falls back to legacy paths (.pi, .claude).
|
||||
*/
|
||||
export async function discoverModels(
|
||||
authStorage: AuthStorage,
|
||||
agentDir: string = getDefaultAgentDir(),
|
||||
): Promise<ModelRegistry> {
|
||||
export function discoverModels(authStorage: AuthStorage, agentDir: string = getDefaultAgentDir()): ModelRegistry {
|
||||
const yamlPath = path.join(agentDir, "models.yml");
|
||||
const jsonPath = path.join(agentDir, "models.json");
|
||||
|
||||
// Check existence of yaml and json files
|
||||
let [yamlExists, jsonExists] = await Promise.all([Bun.file(yamlPath).exists(), Bun.file(jsonPath).exists()]);
|
||||
let yamlExists = fs.existsSync(yamlPath);
|
||||
let jsonExists = fs.existsSync(jsonPath);
|
||||
|
||||
// Migrate models.json to models.yml if yaml doesn't exist but json does
|
||||
if (!yamlExists && jsonExists) {
|
||||
await migrateModelsJsonToYaml(jsonPath, yamlPath);
|
||||
[yamlExists, jsonExists] = await Promise.all([Bun.file(yamlPath).exists(), Bun.file(jsonPath).exists()]);
|
||||
migrateModelsJsonToYaml(jsonPath, yamlPath);
|
||||
yamlExists = fs.existsSync(yamlPath);
|
||||
jsonExists = fs.existsSync(jsonPath);
|
||||
}
|
||||
|
||||
// Prefer models.yml, fall back to models.json
|
||||
@@ -297,36 +296,30 @@ export async function discoverModels(
|
||||
const yamlPaths = getConfigDirPaths("models.yml", { project: false });
|
||||
const jsonPaths = getConfigDirPaths("models.json", { project: false });
|
||||
const allPaths = [...yamlPaths, ...jsonPaths];
|
||||
const existenceResults = await Promise.all(
|
||||
allPaths.map((p) =>
|
||||
Bun.file(p)
|
||||
.exists()
|
||||
.then((exists) => ({ p, exists })),
|
||||
),
|
||||
);
|
||||
const existenceResults = allPaths.map((p) => {
|
||||
return { p, exists: fs.existsSync(p) };
|
||||
});
|
||||
const fallbackPaths = existenceResults.filter(({ p, exists }) => p !== primaryPath && exists).map(({ p }) => p);
|
||||
|
||||
logger.debug("discoverModels", { primaryPath, fallbackPaths });
|
||||
|
||||
const registry = new ModelRegistry(authStorage, primaryPath, fallbackPaths);
|
||||
await registry.refresh();
|
||||
return registry;
|
||||
return new ModelRegistry(authStorage, primaryPath, fallbackPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate models.json to models.yml.
|
||||
* Creates models.yml from models.json and renames the json file to .bak.
|
||||
*/
|
||||
async function migrateModelsJsonToYaml(jsonPath: string, yamlPath: string): Promise<void> {
|
||||
function migrateModelsJsonToYaml(jsonPath: string, yamlPath: string): void {
|
||||
try {
|
||||
const content = await Bun.file(jsonPath).text();
|
||||
const content = fs.readFileSync(jsonPath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
logger.warn("migrateModelsJsonToYaml: invalid models.json structure", { path: jsonPath });
|
||||
return;
|
||||
}
|
||||
await Bun.write(yamlPath, YAML.stringify(parsed, null, 2));
|
||||
await fs.rename(jsonPath, `${jsonPath}.bak`);
|
||||
fs.mkdirSync(path.dirname(yamlPath), { recursive: true });
|
||||
fs.writeFileSync(yamlPath, YAML.stringify(parsed, null, 2));
|
||||
fs.renameSync(jsonPath, `${jsonPath}.bak`);
|
||||
logger.debug("migrateModelsJsonToYaml: migrated models.json to models.yml", { from: jsonPath, to: yamlPath });
|
||||
} catch (error) {
|
||||
logger.warn("migrateModelsJsonToYaml: migration failed", { error: String(error) });
|
||||
@@ -608,7 +601,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
|
||||
// Use provided or create AuthStorage and ModelRegistry
|
||||
const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir));
|
||||
const modelRegistry = options.modelRegistry ?? (await discoverModels(authStorage, agentDir));
|
||||
const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir);
|
||||
time("discoverModels");
|
||||
|
||||
const settingsManager = options.settingsManager ?? (await SettingsManager.create(cwd, agentDir));
|
||||
|
||||
@@ -27,14 +27,15 @@ import type {
|
||||
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
|
||||
import { abortableSleep, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import * as fs from "node:fs";
|
||||
import type { Rule } from "../capability/rule";
|
||||
import { getAgentDbPath } from "../config";
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { parseModelString } from "../config/model-resolver";
|
||||
import {
|
||||
expandPromptTemplate,
|
||||
type PromptTemplate,
|
||||
parseCommandArgs,
|
||||
type PromptTemplate,
|
||||
renderPromptTemplate,
|
||||
} from "../config/prompt-templates";
|
||||
import type { SettingsManager, SkillsSettings } from "../config/settings-manager";
|
||||
@@ -69,10 +70,10 @@ import { resolveToCwd } from "../tools/path-utils";
|
||||
import type { TodoItem } from "../tools/todo-write";
|
||||
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
|
||||
import {
|
||||
type CompactionResult,
|
||||
calculateContextTokens,
|
||||
collectEntriesForBranchSummary,
|
||||
compact,
|
||||
type CompactionResult,
|
||||
estimateTokens,
|
||||
generateBranchSummary,
|
||||
prepareCompaction,
|
||||
@@ -80,8 +81,8 @@ import {
|
||||
} from "./compaction";
|
||||
import {
|
||||
type BashExecutionMessage,
|
||||
type BranchSummaryMessage,
|
||||
bashExecutionToText,
|
||||
type BranchSummaryMessage,
|
||||
type CompactionSummaryMessage,
|
||||
type CustomMessage,
|
||||
type FileMentionMessage,
|
||||
@@ -577,7 +578,7 @@ export class AgentSession {
|
||||
this._streamingEditFileCache.clear();
|
||||
}
|
||||
|
||||
private _preCacheStreamingEditFile(event: AgentEvent): void {
|
||||
private async _preCacheStreamingEditFile(event: AgentEvent): Promise<void> {
|
||||
if (!this.settingsManager.getEditStreamingAbort()) return;
|
||||
if (event.type !== "message_update") return;
|
||||
const assistantEvent = event.assistantMessageEvent;
|
||||
@@ -598,14 +599,14 @@ export class AgentSession {
|
||||
if (!path) return;
|
||||
|
||||
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
|
||||
void this._ensureFileCache(resolvedPath);
|
||||
this._ensureFileCache(resolvedPath);
|
||||
}
|
||||
|
||||
private async _ensureFileCache(resolvedPath: string): Promise<void> {
|
||||
private _ensureFileCache(resolvedPath: string): void {
|
||||
if (this._streamingEditFileCache.has(resolvedPath)) return;
|
||||
|
||||
try {
|
||||
const rawText = await Bun.file(resolvedPath).text();
|
||||
const rawText = fs.readFileSync(resolvedPath, "utf-8");
|
||||
const { text } = stripBom(rawText);
|
||||
this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
|
||||
} catch {
|
||||
|
||||
@@ -219,11 +219,11 @@ describe("applyPatch adversarial inputs", () => {
|
||||
|
||||
test("preserves UTF-8 BOM and CRLF endings", async () => {
|
||||
const filePath = path.join(tempDir, "bom.txt");
|
||||
await Bun.write(filePath, "\uFEFFfoo\r\nbar\r\n");
|
||||
fs.writeFileSync(filePath, "\uFEFFfoo\r\nbar\r\n");
|
||||
|
||||
await applyPatch({ path: "bom.txt", op: "update", diff: "@@\n-foo\n+FOO" }, { cwd: tempDir });
|
||||
|
||||
const content = await Bun.file(filePath).text();
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
expect(content).toBe("\uFEFFFOO\r\nbar\r\n");
|
||||
});
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("ModelRegistry", () => {
|
||||
}
|
||||
|
||||
describe("baseUrl override (no custom models)", () => {
|
||||
test("overriding baseUrl keeps all built-in models", () => {
|
||||
test("overriding baseUrl keeps all built-in models", async () => {
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://my-proxy.example.com/v1"),
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe("ModelRegistry", () => {
|
||||
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
||||
});
|
||||
|
||||
test("overriding baseUrl changes URL on all built-in models", () => {
|
||||
test("overriding baseUrl changes URL on all built-in models", async () => {
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://my-proxy.example.com/v1"),
|
||||
});
|
||||
@@ -92,7 +92,7 @@ describe("ModelRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("overriding headers merges with model headers", () => {
|
||||
test("overriding headers merges with model headers", async () => {
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://my-proxy.example.com/v1", {
|
||||
"X-Custom-Header": "custom-value",
|
||||
@@ -107,7 +107,7 @@ describe("ModelRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("baseUrl-only override does not affect other providers", () => {
|
||||
test("baseUrl-only override does not affect other providers", async () => {
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://my-proxy.example.com/v1"),
|
||||
});
|
||||
@@ -120,7 +120,7 @@ describe("ModelRegistry", () => {
|
||||
expect(googleModels[0].baseUrl).not.toBe("https://my-proxy.example.com/v1");
|
||||
});
|
||||
|
||||
test("can mix baseUrl override and full replacement", () => {
|
||||
test("can mix baseUrl override and full replacement", async () => {
|
||||
writeRawModelsJson({
|
||||
// baseUrl-only for anthropic
|
||||
anthropic: overrideConfig("https://anthropic-proxy.example.com/v1"),
|
||||
@@ -164,7 +164,7 @@ describe("ModelRegistry", () => {
|
||||
});
|
||||
|
||||
describe("provider replacement (with custom models)", () => {
|
||||
test("custom provider with same name as built-in replaces built-in models", () => {
|
||||
test("custom provider with same name as built-in replaces built-in models", async () => {
|
||||
writeModelsJson({
|
||||
anthropic: providerConfig("https://my-proxy.example.com/v1", [{ id: "claude-custom" }]),
|
||||
});
|
||||
@@ -177,7 +177,7 @@ describe("ModelRegistry", () => {
|
||||
expect(anthropicModels[0].baseUrl).toBe("https://my-proxy.example.com/v1");
|
||||
});
|
||||
|
||||
test("custom provider with same name as built-in does not affect other built-in providers", () => {
|
||||
test("custom provider with same name as built-in does not affect other built-in providers", async () => {
|
||||
writeModelsJson({
|
||||
anthropic: providerConfig("https://my-proxy.example.com/v1", [{ id: "claude-custom" }]),
|
||||
});
|
||||
@@ -188,7 +188,7 @@ describe("ModelRegistry", () => {
|
||||
expect(getModelsForProvider(registry, "openai").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("multiple built-in providers can be overridden", () => {
|
||||
test("multiple built-in providers can be overridden", async () => {
|
||||
writeModelsJson({
|
||||
anthropic: providerConfig("https://anthropic-proxy.example.com/v1", [{ id: "claude-proxy" }]),
|
||||
google: providerConfig(
|
||||
|
||||
@@ -108,7 +108,6 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi
|
||||
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
|
||||
const modelRegistry = new ModelRegistry(authStorage, tempDir);
|
||||
|
||||
const session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
|
||||
@@ -10,5 +10,9 @@ echo ""
|
||||
echo "=== Testing source install ==="
|
||||
podman build -f scripts/install-tests/source.dockerfile -t omp-test-source .
|
||||
|
||||
echo ""
|
||||
echo "=== Testing tarball install (publish simulation) ==="
|
||||
podman build -f scripts/install-tests/tarball.dockerfile -t omp-test-tarball .
|
||||
|
||||
echo ""
|
||||
echo "=== All tests passed ==="
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Test tarball install (simulates npm publish flow)
|
||||
# Uses verdaccio as local registry to test full publish/install cycle
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y curl ca-certificates unzip jq procps && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:$PATH"
|
||||
|
||||
# Install Node.js (needed for verdaccio and npm)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install verdaccio (local npm registry)
|
||||
RUN npm install -g verdaccio
|
||||
|
||||
# Copy local repo
|
||||
WORKDIR /repo
|
||||
COPY . .
|
||||
|
||||
# Build the project
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Create verdaccio config (allow anonymous publish)
|
||||
RUN mkdir -p /root/.config/verdaccio && cat > /root/.config/verdaccio/config.yaml <<'EOF'
|
||||
storage: /verdaccio/storage
|
||||
auth:
|
||||
htpasswd:
|
||||
file: /verdaccio/htpasswd
|
||||
max_users: -1
|
||||
uplinks:
|
||||
npmjs:
|
||||
url: https://registry.npmjs.org/
|
||||
packages:
|
||||
'@oh-my-pi/*':
|
||||
access: $all
|
||||
publish: $all
|
||||
unpublish: $all
|
||||
'**':
|
||||
access: $all
|
||||
publish: $all
|
||||
unpublish: $all
|
||||
proxy: npmjs
|
||||
publish:
|
||||
allow_offline: true
|
||||
security:
|
||||
api:
|
||||
legacy: true
|
||||
log: { type: stdout, format: pretty, level: warn }
|
||||
EOF
|
||||
|
||||
# Create storage and htpasswd
|
||||
RUN mkdir -p /verdaccio/storage && touch /verdaccio/htpasswd
|
||||
|
||||
# Create .npmrc with auth token
|
||||
RUN echo '//localhost:4873/:_authToken="fake-token"' > /root/.npmrc
|
||||
|
||||
# Create script to resolve workspace:* versions and publish
|
||||
RUN cat > /repo/scripts/publish-local.sh <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
REGISTRY="http://localhost:4873"
|
||||
PACKAGES=(pi-utils ai agent tui stats coding-agent)
|
||||
|
||||
# Build version map from all package.json files
|
||||
declare -A VERSION_MAP
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
name=$(jq -r '.name' "packages/$pkg/package.json")
|
||||
version=$(jq -r '.version' "packages/$pkg/package.json")
|
||||
VERSION_MAP[$name]=$version
|
||||
echo "Found $name@$version"
|
||||
done
|
||||
|
||||
# Resolve workspace:* in each package.json and publish
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
echo ""
|
||||
echo "=== Publishing packages/$pkg ==="
|
||||
cd "/repo/packages/$pkg"
|
||||
|
||||
# Backup original
|
||||
cp package.json package.json.bak
|
||||
|
||||
# Resolve workspace:* references
|
||||
for dep_name in "${!VERSION_MAP[@]}"; do
|
||||
dep_version="${VERSION_MAP[$dep_name]}"
|
||||
# Replace "workspace:*" with actual version for this dependency
|
||||
jq --arg name "$dep_name" --arg ver "$dep_version" \
|
||||
'(.dependencies[$name] // empty) |= (if . == "workspace:*" then $ver else . end) |
|
||||
(.devDependencies[$name] // empty) |= (if . == "workspace:*" then $ver else . end) |
|
||||
(.peerDependencies[$name] // empty) |= (if . == "workspace:*" then $ver else . end)' \
|
||||
package.json > package.json.tmp && mv package.json.tmp package.json
|
||||
done
|
||||
|
||||
# Show what we're publishing
|
||||
echo "Dependencies:"
|
||||
jq '.dependencies | to_entries[] | select(.value | startswith("@oh-my-pi") or startswith("workspace"))' package.json 2>/dev/null || true
|
||||
|
||||
# Publish
|
||||
npm publish --registry "$REGISTRY"
|
||||
|
||||
# Restore original
|
||||
mv package.json.bak package.json
|
||||
|
||||
cd /repo
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== All packages published ==="
|
||||
SCRIPT
|
||||
RUN chmod +x /repo/scripts/publish-local.sh
|
||||
|
||||
# Start verdaccio and publish all packages
|
||||
RUN verdaccio --config /root/.config/verdaccio/config.yaml &>/dev/null & \
|
||||
sleep 3 && \
|
||||
/repo/scripts/publish-local.sh && \
|
||||
pkill -f verdaccio
|
||||
|
||||
# Clean install in fresh directory
|
||||
WORKDIR /test
|
||||
RUN verdaccio --config /root/.config/verdaccio/config.yaml &>/dev/null & \
|
||||
sleep 3 && \
|
||||
bun add @oh-my-pi/pi-coding-agent --registry http://localhost:4873 && \
|
||||
pkill -f verdaccio
|
||||
|
||||
# Verify the installed package works
|
||||
ENV PATH="/test/node_modules/.bin:$PATH"
|
||||
RUN omp --version
|
||||
Reference in New Issue
Block a user