From ea1e4fe062408e6ccbd9e70da6c9d1a70be91906 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 16:11:42 -0700 Subject: [PATCH] fix: load live workspace plugin over stale staged bundle in dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the durability gap behind "grok chat returns empty replies". In a source checkout the dashboard resolved the staged CLI tsup bundle (packages/cli/dist/plugins//bundled.js), which resolvePluginEntryPath prefers verbatim with no freshness check. The FN-7779 dev prebuild rebuilds each plugin's own plugins//dist but never the staged tsup bundle, so a source-only plugin fix ran stale until a manual `pnpm build`. getCandidatePluginDirs now probes the live workspace source dir (/plugins/) before the staged bundle, so dev loads the freshness-checked live plugin (dist-vs-src), self-healing even when the prebuild is skipped. The global-staged dir stays first, so published installs (no workspace dir) are unaffected — asserted by the retained global-install regression test plus a new source-checkout preference test. Verified on a live dashboard: grok chat streams text and the loader now writes its reload copies under plugins/fusion-plugin-grok-runtime/dist, not the staged bundle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../grok-cli-chat-routing-and-stale-bundle.md | 2 +- .../__tests__/bundled-plugin-install.test.ts | 38 +++++++++++++++++++ .../cli/src/plugins/bundled-plugin-install.ts | 27 ++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/.changeset/grok-cli-chat-routing-and-stale-bundle.md b/.changeset/grok-cli-chat-routing-and-stale-bundle.md index 47007f87c4..d258036a0d 100644 --- a/.changeset/grok-cli-chat-routing-and-stale-bundle.md +++ b/.changeset/grok-cli-chat-routing-and-stale-bundle.md @@ -4,4 +4,4 @@ summary: Fix Grok CLI chat returning errors or empty replies in the dashboard. category: fix -dev: Two independent defects. (1) The default (no-project) ChatManager received a bare PluginLoader as its runner; Grok CLI routing (deriveGrokRuntimeHintForNoVisibleKey → resolveRuntime) calls getRuntimeById/createRuntimeContext, which only exist on PluginRunner, so a grok-cli/* chat with no Fusion-visible GROK_API_KEY threw "getRuntimeById is not a function" and surfaced the misleading "requires the bundled Grok CLI runtime" error. New resolveChatManagerPluginRunner(options) prefers the engine's PluginRunner (the runner the project-scoped chat path already uses), falling back to the loader only in UI-only mode. (2) The CLI-bundled Grok plugin (packages/cli/dist/plugins/fusion-plugin-grok-runtime/bundled.js) was stale relative to the FN-7796 single-JSON adapter source, so project-scoped grok chat produced empty replies; a `pnpm build` (tsup) regenerates it. The bundled-plugin freshness guard only warns — the dev prebuild does not rebuild the CLI tsup bundle. +dev: Two independent defects. (1) The default (no-project) ChatManager received a bare PluginLoader as its runner; Grok CLI routing (deriveGrokRuntimeHintForNoVisibleKey → resolveRuntime) calls getRuntimeById/createRuntimeContext, which only exist on PluginRunner, so a grok-cli/* chat with no Fusion-visible GROK_API_KEY threw "getRuntimeById is not a function" and surfaced the misleading "requires the bundled Grok CLI runtime" error. New resolveChatManagerPluginRunner(options) prefers the engine's PluginRunner (the runner the project-scoped chat path already uses), falling back to the loader only in UI-only mode. (2) In a source checkout the running dashboard resolved the staged CLI tsup bundle (packages/cli/dist/plugins/fusion-plugin-grok-runtime/bundled.js), which resolvePluginEntryPath prefers verbatim with no freshness check; that bundle was stale vs the FN-7796 single-JSON adapter source (the FN-7779 dev prebuild rebuilds each plugin's own dist but never the staged bundle), so project-scoped grok chat produced empty replies. Fixed durably: getCandidatePluginDirs now probes the live workspace source dir (/plugins/) before the staged bundle, so dev loads the freshness-checked live plugin (self-healing even when the prebuild is skipped). Published installs are unaffected (no workspace dir). A one-time `pnpm build` refreshes any already-stale staged bundle. diff --git a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts index 610bd0669f..cdce52b4f9 100644 --- a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts +++ b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts @@ -210,6 +210,44 @@ describe("CLI candidate bundle-directory resolution", () => { expect(result).toBe("installed"); }); + /* + FNXC:PluginLoader 2026-07-10-00:00: + Durability regression for "grok chat returns empty replies". In a source + checkout BOTH the staged tsup bundle (/dist/plugins//bundled.js) and + the live workspace source (/plugins/) exist. resolvePluginEntryPath + prefers bundled.js with no freshness check, so the stale staged bundle used to + win and shadow source-only plugin fixes. The workspace source dir must now be + probed first so dev loads the freshness-checked live plugin, not the stale bundle. + */ + it("prefers the live workspace source over the staged tsup bundle in a source checkout", async () => { + const ID = "fusion-plugin-grok-runtime"; + mockExistsSync.mockImplementation((p: string) => { + if (typeof p !== "string") return false; + // Staged tsup bundle (would win under the old order). + if (p.endsWith(`/dist/plugins/${ID}/manifest.json`)) return true; + if (p.endsWith(`/dist/plugins/${ID}/bundled.js`)) return true; + // Live workspace source dir: /plugins/ (NOT under /dist/plugins/). + if (p.includes("/dist/plugins/")) return false; + if (p.endsWith(`/plugins/${ID}/manifest.json`)) return true; + if (p.endsWith(`/plugins/${ID}/dist/index.js`)) return true; + return false; + }); + mockReadFile.mockResolvedValue(JSON.stringify(makeManifest({ id: ID, name: "Grok Runtime" }))); + mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] }); + + const store = makePluginStore(); + const loader = makePluginLoader(); + + const result = await ensureBundledPluginInstalled(store as never, loader as never, ID); + + expect(result).toBe("installed"); + const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string }; + // Must resolve the live workspace entry, never the stale staged bundle. + expect(registerCall.path.endsWith(`/plugins/${ID}/dist/index.js`)).toBe(true); + expect(registerCall.path).not.toContain("/dist/plugins/"); + expect(registerCall.path.endsWith("bundled.js")).toBe(false); + }); + it("returns missing-bundle when no CLI candidate dir has a manifest", async () => { mockExistsSync.mockReturnValue(false); const store = makePluginStore(); diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index c4fb940ccd..6b3cac4241 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -27,18 +27,41 @@ import { export { BUNDLED_PLUGIN_IDS, isBundledPluginId, resolvePluginEntryPath } from "@fusion/core"; export type { BundledPluginId, EnsureBundledResult } from "@fusion/core"; +/* +FNXC:PluginLoader 2026-07-10-00:00: +Candidate order encodes a freshness contract, not just a search path. + +Published/global install (the regression the first candidate protects): plugins +are staged next to the running bin at `/dist/plugins/`, and no workspace +`plugins/` dir exists — so `join(moduleDir, "plugins", )` MUST stay first and +win. + +Source checkout / `pnpm dev` (the durability fix): the running dashboard would +otherwise resolve the STAGED tsup bundle at `/dist/plugins//bundled.js`, +which `resolvePluginEntryPath` prefers verbatim with NO freshness check. That +bundle is a build artifact only `tsup` regenerates — the FN-7779 dev prebuild +rebuilds each plugin's OWN `plugins//dist` but never the staged bundle, so a +source-only plugin fix (e.g. the FN-7796 Grok adapter) silently ran stale and +grok chat returned empty replies. Probe the workspace source dir +(`/plugins/`) BEFORE the staged bundle so dev loads the live plugin +whose entry `resolvePluginEntryPath` freshness-checks (dist vs src) — self-healing +even when the prebuild is skipped. The workspace dir only exists in a checkout, so +published installs are unaffected. +*/ function getCandidatePluginDirs(pluginId: string): string[] { const moduleDir = dirname(fileURLToPath(import.meta.url)); const cliPackageRoot = resolve(moduleDir, "..", ".."); return [ // Bundled/global runtime: moduleDir is typically /dist, and plugins are - // staged under /dist/plugins/. + // staged under /dist/plugins/. Keep first for the global-install regression. join(moduleDir, "plugins", pluginId), + // Source checkout: prefer the live workspace plugin (freshness-checked by + // resolvePluginEntryPath) over the stale staged tsup bundle below. + join(cliPackageRoot, "..", "..", "plugins", pluginId), // Source/dev fallbacks. join(cliPackageRoot, "dist", "plugins", pluginId), join(cliPackageRoot, "plugins", pluginId), - join(cliPackageRoot, "..", "..", "plugins", pluginId), ]; }