diff --git a/src/lib/inference/ollama/proxy.ts b/src/lib/inference/ollama/proxy.ts index 0ef15457ff..0214e50285 100644 --- a/src/lib/inference/ollama/proxy.ts +++ b/src/lib/inference/ollama/proxy.ts @@ -633,7 +633,7 @@ function isProxyNonInteractive(): boolean { // at module-load time). isNonInteractive is exported from onboard.ts; // fall back to the env var if onboard hasn't fully loaded yet. try { - const onboardMod = require("./onboard"); + const onboardMod = require("../../onboard"); if (typeof onboardMod.isNonInteractive === "function") { return Boolean(onboardMod.isNonInteractive()); } @@ -648,7 +648,7 @@ function isProxyAutoYes(): boolean { // The interactive override prompt path still covers --yes-only invocations // because non-interactive mode is the gate that matters here. try { - const onboardMod = require("./onboard"); + const onboardMod = require("../../onboard"); if (typeof onboardMod.isAutoYes === "function") { return Boolean(onboardMod.isAutoYes()); } @@ -662,7 +662,7 @@ async function promptProxyYesNo(question: string, defaultIsYes: boolean): Promis // Prefer onboard's promptYesNoOrDefault so we get the same indicator // formatting and non-interactive note. Lazy-require to avoid the cycle. try { - const onboardMod = require("./onboard"); + const onboardMod = require("../../onboard"); if (typeof onboardMod.promptYesNoOrDefault === "function") { return Boolean(await onboardMod.promptYesNoOrDefault(question, null, defaultIsYes)); } diff --git a/test/ollama-tools-capability.test.ts b/test/ollama-tools-capability.test.ts index 6a9fe91faf..00fefd94da 100644 --- a/test/ollama-tools-capability.test.ts +++ b/test/ollama-tools-capability.test.ts @@ -9,6 +9,7 @@ const require = createRequire(import.meta.url); const REPO_ROOT = path.join(import.meta.dirname, ".."); const LOCAL_INFERENCE_PATH = path.join(REPO_ROOT, "dist", "lib", "inference", "local.js"); const ONBOARD_OLLAMA_PROXY_PATH = path.join(REPO_ROOT, "dist", "lib", "inference", "ollama", "proxy.js"); +const ONBOARD_PATH = path.join(REPO_ROOT, "dist", "lib", "onboard.js"); type CapturedCall = { argv: readonly string[]; opts?: Record }; @@ -43,6 +44,16 @@ interface OnboardOllamaProxyModule { ) => Promise<{ ok: boolean; message?: string }>; } +interface OnboardModuleExports { + isAutoYes?: () => boolean; + isNonInteractive?: () => boolean; + promptYesNoOrDefault?: ( + question: string, + envVar: string | null, + defaultIsYes: boolean, + ) => boolean | Promise; +} + function loadLocalInference(): LocalInferenceModule { return require(LOCAL_INFERENCE_PATH) as LocalInferenceModule; } @@ -211,6 +222,7 @@ interface ProxyTestHarness { logs: string[]; errors: string[]; promptCalls: string[]; + setOnboardExports: (exports: OnboardModuleExports) => void; setProbeResult: (caps: OllamaCapabilities) => void; setPromptReply: (reply: string) => void; } @@ -225,10 +237,38 @@ const SHARED = { promptReply: "", promptCalls: [] as string[], installed: false, + onboardExports: {} as OnboardModuleExports, originalProbe: undefined as undefined | LocalInferenceModule["probeOllamaModelCapabilities"], originalPrompt: undefined as undefined | ((msg: string) => Promise), + originalOnboardCacheEntry: undefined as undefined | NodeJS.Module, }; +function resetOnboardExports(): void { + for (const key of Object.keys(SHARED.onboardExports) as Array) { + delete SHARED.onboardExports[key]; + } +} + +function installOnboardModuleStub(): void { + require.cache[ONBOARD_PATH] = { + id: ONBOARD_PATH, + filename: ONBOARD_PATH, + loaded: true, + exports: SHARED.onboardExports, + children: [], + path: path.dirname(ONBOARD_PATH), + paths: [], + } as unknown as NodeJS.Module; +} + +function restoreOnboardModuleStub(): void { + if (SHARED.originalOnboardCacheEntry) { + require.cache[ONBOARD_PATH] = SHARED.originalOnboardCacheEntry; + return; + } + delete require.cache[ONBOARD_PATH]; +} + function installSharedStubs(): void { if (SHARED.installed) return; const localInference = loadLocalInference() as LocalInferenceModule & { @@ -248,6 +288,7 @@ function installSharedStubs(): void { SHARED.promptCalls.push(msg); return SHARED.promptReply; }; + SHARED.originalOnboardCacheEntry = require.cache[ONBOARD_PATH]; SHARED.installed = true; } @@ -262,6 +303,8 @@ function loadProxyWithStubs(): ProxyTestHarness { }; SHARED.promptReply = ""; SHARED.promptCalls.length = 0; + resetOnboardExports(); + installOnboardModuleStub(); // Invalidate the cache and re-require so the proxy module's // destructured `probeOllamaModelCapabilities` binding picks up the @@ -282,6 +325,8 @@ function loadProxyWithStubs(): ProxyTestHarness { const restore = () => { logSpy.mockRestore(); errSpy.mockRestore(); + restoreOnboardModuleStub(); + delete require.cache[ONBOARD_OLLAMA_PROXY_PATH]; }; (loadProxyWithStubs as unknown as { _restore?: () => void })._restore = restore; @@ -290,6 +335,9 @@ function loadProxyWithStubs(): ProxyTestHarness { logs, errors, promptCalls: SHARED.promptCalls, + setOnboardExports: (exports) => { + Object.assign(SHARED.onboardExports, exports); + }, setProbeResult: (caps) => { SHARED.scriptedCaps = caps; }, @@ -367,6 +415,20 @@ describe("checkOllamaModelToolSupport", () => { expect(h.errors.some((e) => e.includes("NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0"))).toBe(true); }); + it("uses onboard non-interactive state when the env var is unset", async () => { + const h = loadProxyWithStubs(); + h.setOnboardExports({ isNonInteractive: () => true }); + h.setProbeResult({ + source: "api", + capabilities: ["completion"], + supportsTools: false, + }); + const out = await h.proxy.checkOllamaModelToolSupport("phi4"); + expect(out.ok).toBe(false); + expect(h.errors.some((e) => e.includes("NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0"))).toBe(true); + expect(h.promptCalls.length).toBe(0); + }); + it("non-interactive + NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0 → {ok:true} after stderr warning", async () => { process.env.NEMOCLAW_NON_INTERACTIVE = "1"; process.env.NEMOCLAW_OLLAMA_REQUIRE_TOOLS = "0"; @@ -401,6 +463,24 @@ describe("checkOllamaModelToolSupport", () => { expect(h.promptCalls.length).toBe(0); }); + it("uses onboard prompt helper when available", async () => { + const h = loadProxyWithStubs(); + const promptYesNoOrDefault = vi.fn(async () => true); + h.setOnboardExports({ + isNonInteractive: () => false, + promptYesNoOrDefault, + }); + h.setProbeResult({ + source: "api", + capabilities: ["completion"], + supportsTools: false, + }); + const out = await h.proxy.checkOllamaModelToolSupport("phi4"); + expect(out).toEqual({ ok: true }); + expect(promptYesNoOrDefault).toHaveBeenCalledWith(" Use this model anyway?", null, false); + expect(h.promptCalls.length).toBe(0); + }); + it("probe failed (capabilities unknown) → {ok:true} (graceful degradation)", async () => { const h = loadProxyWithStubs(); h.setProbeResult({