diff --git a/packages/cli/src/commands/doctor-pi.test.ts b/packages/cli/src/commands/doctor-pi.test.ts index a25721f26..924d38fc3 100644 --- a/packages/cli/src/commands/doctor-pi.test.ts +++ b/packages/cli/src/commands/doctor-pi.test.ts @@ -374,6 +374,192 @@ describe("Pi doctor", () => { expect(output).toContain("WARN 2"); }); + it("skips unrelated local dev-path packages and broken trees when probing the embedding runtime", async () => { + const root = makeTempRoot(); + const cwd = makeTempRoot("mc-pi-doctor-cwd-"); + const agentDir = setEnv(root, cwd); + writeHealthyFiles(agentDir, cwd); + + // Unrelated local extension: has a package.json but is NOT the + // magic-context plugin. Must not be probed as an embedding candidate. + const unrelatedPlugin = makeTempRoot("mc-pi-doctor-unrelated-"); + writeFileSync( + join(unrelatedPlugin, "package.json"), + JSON.stringify({ name: "pi-tree-git-checkpoint", version: "0.0.0" }), + ); + // Local dev tree of the actual plugin that is missing all embedding deps. + const brokenDevTree = makeTempRoot("mc-pi-doctor-dev-"); + writeFileSync( + join(brokenDevTree, "package.json"), + JSON.stringify({ name: "@cortexkit/pi-magic-context", version: "0.0.0-dev" }), + ); + + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ + packages: ["npm:@cortexkit/pi-magic-context", unrelatedPlugin, brokenDevTree], + }), + ); + createInstalledPiPlugin(agentDir, true); + const prompts = new MockPrompts(); + + const code = await runDoctor(baseOptions(root, cwd, prompts)); + + expect(code).toBe(1); + const output = prompts.messages.join("\n"); + expect(output).toContain("Multiple magic-context entries in Pi packages[]"); + expect(output).toContain("PASS Embedding provider: local (native runtime selected and OK)"); + expect(output).not.toContain( + "WARN Embedding provider: local — native runtime and WASM fallback both unavailable", + ); + }); + + it("prefers a later native-capable install over an earlier WASM fallback", async () => { + const root = makeTempRoot(); + const cwd = makeTempRoot("mc-pi-doctor-cwd-"); + const agentDir = setEnv(root, cwd); + writeHealthyFiles(agentDir, cwd); + + // Local dev tree of the actual plugin with only a WASM fallback + // (no native binding) — probing it alone would report a degraded + // runtime. + const wasmDevTree = makeTempRoot("mc-pi-doctor-wasm-dev-"); + mkdirSync(join(wasmDevTree, "node_modules", "onnxruntime-web"), { + recursive: true, + }); + writeFileSync( + join(wasmDevTree, "node_modules", "onnxruntime-web", "package.json"), + JSON.stringify({ name: "onnxruntime-web", main: "index.js" }), + ); + writeFileSync( + join(wasmDevTree, "node_modules", "onnxruntime-web", "index.js"), + "module.exports = {};\n", + ); + mkdirSync(join(wasmDevTree, "dist"), { recursive: true }); + writeFileSync(join(wasmDevTree, "dist", "transformers-node-wasm.js"), "export {};\n"); + writeFileSync( + join(wasmDevTree, "package.json"), + JSON.stringify({ name: "@cortexkit/pi-magic-context", version: "0.0.0-dev" }), + ); + + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ + packages: ["npm:@cortexkit/pi-magic-context", wasmDevTree], + }), + ); + createInstalledPiPlugin(agentDir, true); + const prompts = new MockPrompts(); + + const code = await runDoctor(baseOptions(root, cwd, prompts)); + + expect(code).toBe(1); + const output = prompts.messages.join("\n"); + expect(output).toContain("Multiple magic-context entries in Pi packages[]"); + expect(output).toContain("PASS Embedding provider: local (native runtime selected and OK)"); + expect(output).not.toContain( + "WARN Embedding provider: local — onnxruntime-node native binding failed", + ); + }); + + it("reports unverified, not a broken-runtime WARN, when only unrelated local packages are registered", async () => { + const root = makeTempRoot(); + const cwd = makeTempRoot("mc-pi-doctor-cwd-"); + const agentDir = setEnv(root, cwd); + writeHealthyFiles(agentDir, cwd); + + // Only an unrelated local extension is registered; the magic-context + // managed install tree is absent. The unrelated package must not be + // probed as an embedding candidate, so doctor reports unverified + // instead of blaming it for a missing onnxruntime. + const unrelatedPlugin = makeTempRoot("mc-pi-doctor-unrelated-"); + writeFileSync( + join(unrelatedPlugin, "package.json"), + JSON.stringify({ name: "pi-tree-git-checkpoint", version: "0.0.0" }), + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ + packages: ["npm:@cortexkit/pi-magic-context", unrelatedPlugin], + }), + ); + const prompts = new MockPrompts(); + + const code = await runDoctor(baseOptions(root, cwd, prompts)); + + expect(code).toBe(0); + const output = prompts.messages.join("\n"); + expect(output).toContain( + "selected runtime unverified (no installed plugin tree found to inspect)", + ); + expect(output).not.toContain( + "WARN Embedding provider: local — native runtime and WASM fallback both unavailable", + ); + }); + + it("reports every broken candidate with its native and WASM reasons", async () => { + const root = makeTempRoot(); + const cwd = makeTempRoot("mc-pi-doctor-cwd-"); + const agentDir = setEnv(root, cwd); + writeHealthyFiles(agentDir, cwd); + const brokenTrees = [makeTempRoot("mc-broken-first-"), makeTempRoot("mc-broken-second-")]; + for (const tree of brokenTrees) { + writeFileSync( + join(tree, "package.json"), + JSON.stringify({ name: "@cortexkit/pi-magic-context" }), + ); + } + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ packages: ["npm:@cortexkit/pi-magic-context", ...brokenTrees] }), + ); + const prompts = new MockPrompts(); + await runDoctor(baseOptions(root, cwd, prompts)); + const warnings = prompts.messages.filter((message) => + message.includes("WARN Embedding provider: local"), + ); + for (const tree of brokenTrees) { + expect( + warnings.some( + (warning) => + warning.includes(tree) && + warning.includes("native:") && + warning.includes("WASM:"), + ), + ).toBe(true); + } + }); + + it.each([ + false, + true, + ])("detects npm plus local Magic Context identity (object source: %s)", async (objectSource) => { + const root = makeTempRoot(); + const cwd = makeTempRoot("mc-pi-doctor-cwd-"); + const agentDir = setEnv(root, cwd); + writeHealthyFiles(agentDir, cwd); + const localDir = join(agentDir, "local-plugin"); + mkdirSync(localDir); + writeFileSync( + join(localDir, "package.json"), + JSON.stringify({ name: "@cortexkit/pi-magic-context" }), + ); + const source = "./local-plugin"; + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ + packages: ["npm:@cortexkit/pi-magic-context", objectSource ? { source } : source], + }), + ); + const prompts = new MockPrompts(); + expect(await runDoctor(baseOptions(root, cwd, prompts))).toBe(1); + const output = prompts.messages.join("\n"); + expect(output).toContain("Multiple magic-context entries in Pi packages[]"); + expect(output).toContain(source); + expect(output).not.toContain("Other Pi extensions registered:"); + expect(output).not.toContain("selected runtime unverified"); + }); + it("reports the WASM fallback when onnxruntime-node is completely absent", async () => { const root = makeTempRoot(); const cwd = makeTempRoot("mc-pi-doctor-cwd-"); diff --git a/packages/cli/src/commands/doctor-pi.ts b/packages/cli/src/commands/doctor-pi.ts index 4fd52d1b1..c7b532785 100644 --- a/packages/cli/src/commands/doctor-pi.ts +++ b/packages/cli/src/commands/doctor-pi.ts @@ -319,18 +319,50 @@ function packagesFrom(settings: Record): unknown[] { * /.pi/npm/node_modules/ (project). We collect every plausible dir * with a package.json; the resolver stays SILENT for any that don't exist. */ +/** True when the directory's package.json declares the magic-context Pi plugin. */ +function isPiMagicContextPackageDir(dir: string): boolean { + const packageJson = join(dir, "package.json"); + if (!existsSync(packageJson)) return false; + try { + const pkg = JSON.parse(readFileSync(packageJson, "utf-8")) as { + name?: unknown; + }; + return typeof pkg.name === "string" && pkg.name === PACKAGE_NAME; + } catch { + return false; + } +} + +function localPiMagicContextPackageDir(entry: unknown): string | null { + const source = + typeof entry === "string" + ? entry + : entry && typeof entry === "object" && "source" in entry + ? entry.source + : null; + const spec = typeof source === "string" ? source.trim() : ""; + if (!spec || spec.startsWith("npm:")) return null; + const dir = isAbsolute(spec) ? spec : join(getPiAgentConfigDir(), spec); + return isPiMagicContextPackageDir(dir) ? dir : null; +} + +function isConfiguredPiMagicContextEntry(entry: unknown): boolean { + return isPiMagicContextPackageEntry(entry) || localPiMagicContextPackageDir(entry) !== null; +} + function piPluginDirCandidates(packages: unknown[], cwd: string): string[] { const dirs: string[] = []; const agentDir = getPiAgentConfigDir(); // Local dev-path entries: a string spec that is NOT an npm: specifier and // resolves to a directory on disk. Relative entries are resolved against the - // Pi agent dir (Pi's settings.packages base). + // Pi agent dir (Pi's settings.packages base). Only directories whose + // package.json names the magic-context plugin itself are candidates — other + // local extensions registered in packages[] must not be probed for the + // embedding runtime. for (const entry of packages) { - const spec = typeof entry === "string" ? entry.trim() : ""; - if (!spec || spec.startsWith("npm:")) continue; - const resolved = isAbsolute(spec) ? spec : join(agentDir, spec); - dirs.push(resolved); + const resolved = localPiMagicContextPackageDir(entry); + if (resolved) dirs.push(resolved); } // Managed npm install roots (hoisted): /node_modules/. @@ -851,6 +883,8 @@ async function runHealthChecks(options: { // persistence-capable Node WASM fallback. Resolution starts from the // installed plugin dir and stays silent when no tree can be inspected. let runtimeReported = false; + let firstFallback: ReturnType | null = null; + const brokenWarnings: string[] = []; let runtimeUnverifiedReason = "no installed plugin tree found to inspect"; for (const pluginDir of piPluginDirCandidates(packages, options.cwd)) { const runtime = checkLocalEmbeddingRuntimeByResolution( @@ -878,23 +912,34 @@ async function runHealthChecks(options: { break; } if (runtime.state === "wasm-fallback") { - add(results, "warn", formatLocalEmbeddingRuntimeWasmFallback(runtime)); - runtimeReported = true; - break; + // Remember the best degraded candidate but keep probing: a WASM + // fallback in an earlier tree must not mask a later native-capable + // install. + firstFallback ??= runtime; + continue; } if (isLocalEmbeddingRuntimeBroken(runtime)) { - add(results, "warn", formatLocalEmbeddingRuntimeDoctorWarning(runtime)); - runtimeReported = true; - break; + // Keep probing: an earlier broken candidate (e.g. a stale local + // dev-path tree) must not mask a healthy managed install. + brokenWarnings.push( + `${formatLocalEmbeddingRuntimeDoctorWarning(runtime)} Candidate: ${pluginDir}`, + ); + continue; } if (runtime.state === "unknown") runtimeUnverifiedReason = runtime.reason; } if (!runtimeReported) { - add( - results, - "warn", - `Embedding provider ${loadedConfig.config.embedding.provider}: selected runtime unverified (${runtimeUnverifiedReason})`, - ); + if (firstFallback) { + add(results, "warn", formatLocalEmbeddingRuntimeWasmFallback(firstFallback)); + } else if (brokenWarnings.length > 0) { + for (const warning of brokenWarnings) add(results, "warn", warning); + } else { + add( + results, + "warn", + `Embedding provider ${loadedConfig.config.embedding.provider}: selected runtime unverified (${runtimeUnverifiedReason})`, + ); + } } } @@ -902,7 +947,7 @@ async function runHealthChecks(options: { // extensions today, but we still check for self-conflicts that the user // can hit (e.g. accidentally registering both an npm entry AND a local // dev-path entry, which causes duplicate plugin loading). - const piEntries = packages.filter(isPiMagicContextPackageEntry).map(describePiPackageEntry); + const piEntries = packages.filter(isConfiguredPiMagicContextEntry).map(describePiPackageEntry); if (piEntries.length > 1) { add( results, @@ -914,7 +959,7 @@ async function runHealthChecks(options: { } const otherExtensions = packages - .filter((entry) => !isPiMagicContextPackageEntry(entry)) + .filter((entry) => !isConfiguredPiMagicContextEntry(entry)) .map(describePiPackageEntry); if (otherExtensions.length > 0) { add(results, "info", `Other Pi extensions registered: ${otherExtensions.join(", ")}`);