fix: Grok ACP skill packaging paths and dead-session follow-up prompts
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user