fix: load live workspace plugin over stale staged bundle in dev

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/<id>/bundled.js), which resolvePluginEntryPath prefers
verbatim with no freshness check. The FN-7779 dev prebuild rebuilds each plugin's
own plugins/<id>/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 (<repo>/plugins/<id>)
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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-10 16:11:42 -07:00
parent 87b787319f
commit ea1e4fe062
3 changed files with 64 additions and 3 deletions

View File

@@ -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 (<repo>/plugins/<id>) 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.

View File

@@ -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 (<cli>/dist/plugins/<id>/bundled.js) and
the live workspace source (<repo>/plugins/<id>) 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: <repo>/plugins/<id> (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();

View File

@@ -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 `<cli>/dist/plugins/<id>`, and no workspace
`plugins/` dir exists — so `join(moduleDir, "plugins", <id>)` MUST stay first and
win.
Source checkout / `pnpm dev` (the durability fix): the running dashboard would
otherwise resolve the STAGED tsup bundle at `<cli>/dist/plugins/<id>/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/<id>/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
(`<repo>/plugins/<id>`) 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 <cli>/dist, and plugins are
// staged under <cli>/dist/plugins/<id>.
// staged under <cli>/dist/plugins/<id>. 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),
];
}