diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts index 8fb8e63811..97e0cd1b4b 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts @@ -226,10 +226,44 @@ describe("GrokRuntimeAdapter (ACP)", () => { expect(session.state.errorMessage).toContain("ENOENT grok"); expect(onText).toHaveBeenCalledWith(expect.stringContaining("ENOENT grok")); - // Second prompt must not duplicate the diagnostic bubble. + /* + FNXC:GrokAcp 2026-07-12-06:15: + Follow-up prompts on a dead session must re-surface a diagnostic (including + the prior error) rather than appending the user turn and returning silently. + */ onText.mockClear(); await adapter.promptWithFallback(session, "hi"); - expect(onText).not.toHaveBeenCalled(); + expect(onText).toHaveBeenCalledWith(expect.stringContaining("no live connection")); + expect(onText).toHaveBeenCalledWith(expect.stringContaining("ENOENT grok")); + expect(session.state.errorMessage).toContain("ENOENT grok"); + }); + + it("re-surfaces diagnostics on follow-up prompts after connection is dropped", async () => { + const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter() }); + const onText = vi.fn(); + const { session } = await adapter.createSession({ onText }); + + // Drop the live ACP connection (simulates dispose / process death mid-session). + delete (session as { connection?: unknown }).connection; + session.state.errorMessage = "ACP bridge hung up"; + + onText.mockClear(); + await expect(adapter.promptWithFallback(session, "still there?")).resolves.toBeUndefined(); + expect(onText).toHaveBeenCalledTimes(1); + expect(onText).toHaveBeenCalledWith(expect.stringContaining("no live connection")); + expect(onText).toHaveBeenCalledWith(expect.stringContaining("ACP bridge hung up")); + expect(session.state.messages?.some((m) => typeof m === "object" && m !== null && (m as { role?: string }).role === "user")).toBe( + true, + ); + expect( + session.state.messages?.some( + (m) => + typeof m === "object" && + m !== null && + (m as { role?: string; content?: string }).role === "assistant" && + String((m as { content?: string }).content ?? "").includes("no live connection"), + ), + ).toBe(true); }); it("describeModel formats grok prefix", async () => { diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts index f46a31311d..5822892829 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts @@ -1,20 +1,33 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { mkdtempSync } from "node:fs"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { buildGrokSkillRules, extractRequestedSkillNames, + getFusionSkillSourceCandidates, resolveBundledFusionSkillSource, stageGrokSessionSkills, } from "../skill-loader.js"; const disposers: Array<() => void> = []; +const envKeysTouched = new Set(); + afterEach(() => { while (disposers.length > 0) disposers.pop()?.(); + for (const key of envKeysTouched) { + delete process.env[key]; + } + envKeysTouched.clear(); }); +function setEnv(key: string, value: string): void { + process.env[key] = value; + envKeysTouched.add(key); +} + describe("skill-loader", () => { it("resolves the bundled Fusion skill from the monorepo", () => { const source = resolveBundledFusionSkillSource(); @@ -22,6 +35,69 @@ describe("skill-loader", () => { expect(existsSync(join(source!, "SKILL.md"))).toBe(true); }); + it("includes monorepo packages/cli/skill/fusion among candidates", () => { + const candidates = getFusionSkillSourceCandidates(); + expect( + candidates.some((c) => c.endsWith(join("packages", "cli", "skill", "fusion")) || c.includes(`${join("packages", "cli", "skill", "fusion")}`)), + ).toBe(true); + }); + + /* + FNXC:GrokAcp 2026-07-12-06:15: + Packaged @runfusion/fusion ships skill/** at package root next to dist/plugins/. + Candidate generation from a synthetic bundled.js URL must reach skill/fusion + without a monorepo tree. + */ + it("generates packaged-install candidates for bundled plugin layout", () => { + const packageRoot = mkdtempSync(join(tmpdir(), "fusion-pkg-")); + const pluginDist = join(packageRoot, "dist", "plugins", "fusion-plugin-grok-runtime"); + mkdirSync(pluginDist, { recursive: true }); + const bundledJs = join(pluginDist, "bundled.js"); + writeFileSync(bundledJs, "// stub\n"); + + const skillDir = join(packageRoot, "skill", "fusion"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fusion\n---\n# fusion\n"); + + const moduleUrl = pathToFileURL(bundledJs).href; + const candidates = getFusionSkillSourceCandidates(moduleUrl); + const resolvedSkill = resolve(skillDir); + + expect(candidates).toContain(resolvedSkill); + expect(resolveBundledFusionSkillSource(moduleUrl)).toBe(resolvedSkill); + }); + + it("walks ancestors for packages/cli/skill/fusion layout", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "fusion-repo-")); + const pluginSrc = join(repoRoot, "plugins", "fusion-plugin-grok-runtime", "src"); + mkdirSync(pluginSrc, { recursive: true }); + const moduleFile = join(pluginSrc, "skill-loader.js"); + writeFileSync(moduleFile, "// stub\n"); + + const skillDir = join(repoRoot, "packages", "cli", "skill", "fusion"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fusion\n---\n# fusion\n"); + + const moduleUrl = pathToFileURL(moduleFile).href; + const candidates = getFusionSkillSourceCandidates(moduleUrl); + expect(candidates).toContain(resolve(skillDir)); + expect(resolveBundledFusionSkillSource(moduleUrl)).toBe(resolve(skillDir)); + }); + + it("prefers FUSION_SKILL_SOURCE when set and valid", () => { + const root = mkdtempSync(join(tmpdir(), "fusion-env-skill-")); + const skillDir = join(root, "custom-fusion"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fusion\n---\n# env\n"); + setEnv("FUSION_SKILL_SOURCE", skillDir); + + const candidates = getFusionSkillSourceCandidates(pathToFileURL(join(root, "missing", "mod.js")).href); + expect(candidates[0]).toBe(resolve(skillDir)); + expect(resolveBundledFusionSkillSource(pathToFileURL(join(root, "missing", "mod.js")).href)).toBe( + resolve(skillDir), + ); + }); + it("stages fusion skill plus additional skill roots into a plugin dir", () => { const extraRoot = mkdtempSync(join(tmpdir(), "extra-skills-")); const skillDir = join(extraRoot, "ce-plan"); @@ -39,6 +115,18 @@ describe("skill-loader", () => { expect(staged.skillNames).toEqual(expect.arrayContaining(["fusion", "ce-plan"])); }); + it("still lists requested fusion skill in rules when staging cannot copy files", () => { + const staged = stageGrokSessionSkills({ + requestedSkillNames: ["fusion"], + includeFusionSkill: false, + }); + disposers.push(staged.dispose); + expect(staged.skillNames).toContain("fusion"); + const rules = buildGrokSkillRules({ skillNames: staged.skillNames, fusionToolCount: 0 }); + expect(rules).toContain("fusion"); + expect(rules).toContain("Use the Fusion skill workflows"); + }); + it("extracts requested skill names from skills or skillSelection", () => { expect(extractRequestedSkillNames({ skills: ["a", "b"] })).toEqual(["a", "b"]); expect( diff --git a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts index b38ebc1534..727c69c165 100644 --- a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts @@ -336,13 +336,18 @@ export class GrokRuntimeAdapter implements AgentRuntime { const hasConnection = acp && "connection" in session && Boolean((session as { connection?: unknown }).connection); + /* + FNXC:GrokAcp 2026-07-12-06:15: + Dead / disposed sessions have no ACP connection. Follow-up prompts must not + append a user message and return silently — always re-surface a diagnostic + via onText + assistant message so multi-turn chat stays visible. Prefer the + previous errorMessage when present so operators still see the root cause. + */ if (!hasConnection) { const existing = grokSession.state.errorMessage?.trim(); - if (existing) { - return; - } - const diagnostic = - "Grok ACP session has no live connection. The `grok agent stdio` process failed to start or was disposed."; + const diagnostic = existing + ? `Grok ACP session has no live connection (previous error: ${existing}). Start a new session to retry.` + : "Grok ACP session has no live connection. The `grok agent stdio` process failed to start or was disposed."; grokSession.state.errorMessage = diagnostic; grokSession.callbacks.onText?.(diagnostic); appendMessage(grokSession, "assistant", diagnostic); diff --git a/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts b/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts index 45aaea9650..19dbeec518 100644 --- a/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts +++ b/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts @@ -7,6 +7,14 @@ Grok loads skills from trusted `--plugin-dir` / `_meta.pluginDirs` plugins - skills from engine additionalSkillPaths / skill roots Requested skill names are also listed in runtime context rules so the agent still sees the selection when a skill file cannot be resolved on disk. + +FNXC:GrokAcp 2026-07-12-06:15: +Packaged `@runfusion/fusion` publishes `skill/**` (not only monorepo +`packages/cli/skill/fusion`). Expand fusion-skill candidates so CLI installs under +`dist/plugins/fusion-plugin-grok-runtime/` still resolve `skill/fusion` at the +package root, via parent walks, createRequire of `@runfusion/fusion/package.json`, +and optional `FUSION_SKILL_SOURCE`. Missing fusion skill must not fail session +create — rules still list requested skills via buildGrokSkillRules. */ import { @@ -19,6 +27,7 @@ import { symlinkSync, writeFileSync, } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -35,21 +44,84 @@ function isSkillDir(dir: string): boolean { return existsSync(join(dir, "SKILL.md")); } -export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { - const here = fileURLToPath(moduleUrl); - const moduleDir = dirname(here); - return [ - // Monorepo source checkout: packages/cli/skill/fusion - resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME), - // Bundled CLI layout: dist/skill/fusion or sibling skill/ - resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME), - resolve(moduleDir, "..", "skill", FUSION_SKILL_NAME), - resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME), - ]; +function pushUnique(out: string[], candidate: string | null | undefined): void { + if (!candidate) return; + const resolved = resolve(candidate); + if (!out.includes(resolved)) out.push(resolved); } -export function resolveBundledFusionSkillSource(): string | null { - for (const candidate of getFusionSkillSourceCandidates()) { +function pushSkillLayoutsAtRoot(out: string[], root: string): void { + pushUnique(out, join(root, "skill", FUSION_SKILL_NAME)); + pushUnique(out, join(root, "packages", "cli", "skill", FUSION_SKILL_NAME)); +} + +function walkAncestorSkillCandidates(out: string[], startDir: string, maxParents = 8): void { + let dir = startDir; + for (let i = 0; i < maxParents; i++) { + pushSkillLayoutsAtRoot(out, dir); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } +} + +function pushPackageRequireCandidates(out: string[], from: string): void { + try { + const require = createRequire(from); + const pkgJson = require.resolve("@runfusion/fusion/package.json"); + pushUnique(out, join(dirname(pkgJson), "skill", FUSION_SKILL_NAME)); + } catch { + // Package not resolvable from this origin (plugin-only tree, tests, etc.). + } +} + +/** + * Ordered candidate directories for the bundled Fusion skill (`skill/fusion` with SKILL.md). + * First existing skill dir wins in resolveBundledFusionSkillSource. + */ +export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { + const candidates: string[] = []; + const envSource = process.env.FUSION_SKILL_SOURCE?.trim(); + if (envSource) { + pushUnique(candidates, envSource); + } + + const here = fileURLToPath(moduleUrl); + const moduleDir = dirname(here); + + // Monorepo source checkout relative to plugins/fusion-plugin-grok-runtime/src + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME)); + // Relative siblings used in various dist layouts + pushUnique(candidates, resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME)); + pushUnique(candidates, resolve(moduleDir, "..", "skill", FUSION_SKILL_NAME)); + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME)); + /* + FNXC:GrokAcp 2026-07-12-06:15: + Published package layout: dist/plugins/fusion-plugin-grok-runtime/* → ../../../skill/fusion + at the @runfusion/fusion package root (files includes skill/**). + */ + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME)); + pushUnique(candidates, resolve(moduleDir, "../../../skill", FUSION_SKILL_NAME)); + + walkAncestorSkillCandidates(candidates, moduleDir, 8); + pushPackageRequireCandidates(candidates, moduleUrl); + + const argv1 = typeof process.argv[1] === "string" ? process.argv[1].trim() : ""; + if (argv1) { + try { + const argvPath = resolve(argv1); + pushPackageRequireCandidates(candidates, argvPath); + walkAncestorSkillCandidates(candidates, dirname(argvPath), 8); + } catch { + // ignore bad argv paths + } + } + + return candidates; +} + +export function resolveBundledFusionSkillSource(moduleUrl = import.meta.url): string | null { + for (const candidate of getFusionSkillSourceCandidates(moduleUrl)) { if (isSkillDir(candidate)) return candidate; } return null;