fix: Grok ACP skill packaging paths and dead-session follow-up prompts

This commit is contained in:
gsxdsm
2026-07-11 23:16:15 -07:00
parent 82f007b273
commit 40a01f1d0f
4 changed files with 220 additions and 21 deletions

View File

@@ -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 () => {

View File

@@ -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<string>();
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(

View File

@@ -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);

View File

@@ -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;