mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-10 04:09:40 +00:00
* fix: spawn step skipped when no explicit --steps passed
The spawn skill injection condition used `enabledSteps?.has("spawn")`
which is falsy when enabledSteps is undefined (no --steps flag). Now
checks the recursive beta flag directly and falls through when no
explicit steps are selected, matching how auto-update works.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: embed skill content in spawn-skill.ts instead of reading from disk
The skills/ directory exists in the repo but isn't bundled when the CLI
is installed via npm. readSkillContent() couldn't find the files at
runtime, causing "No spawn skill file for agent" on every deploy.
Fixed by embedding all skill content directly as string constants in the
module. Removed fs-based getSkillsDir/readSkillContent/getSpawnSkillSourceFile
in favor of a single AGENT_SKILLS config map with inline content.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
287 lines
8.9 KiB
TypeScript
287 lines
8.9 KiB
TypeScript
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
import { getSkillContent, getSpawnSkillPath, injectSpawnSkill, isAppendMode } from "../shared/spawn-skill.js";
|
|
|
|
// ─── Path mapping tests ─────────────────────────────────────────────────────
|
|
|
|
describe("getSpawnSkillPath", () => {
|
|
it("returns correct path for claude", () => {
|
|
expect(getSpawnSkillPath("claude")).toBe("~/.claude/skills/spawn/SKILL.md");
|
|
});
|
|
|
|
it("returns correct path for codex", () => {
|
|
expect(getSpawnSkillPath("codex")).toBe("~/.agents/skills/spawn/SKILL.md");
|
|
});
|
|
|
|
it("returns correct path for openclaw", () => {
|
|
expect(getSpawnSkillPath("openclaw")).toBe("~/.openclaw/skills/spawn/SKILL.md");
|
|
});
|
|
|
|
it("returns correct path for zeroclaw", () => {
|
|
expect(getSpawnSkillPath("zeroclaw")).toBe("~/.zeroclaw/workspace/AGENTS.md");
|
|
});
|
|
|
|
it("returns correct path for opencode", () => {
|
|
expect(getSpawnSkillPath("opencode")).toBe("~/.config/opencode/AGENTS.md");
|
|
});
|
|
|
|
it("returns correct path for kilocode", () => {
|
|
expect(getSpawnSkillPath("kilocode")).toBe("~/.kilocode/rules/spawn.md");
|
|
});
|
|
|
|
it("returns correct path for hermes", () => {
|
|
expect(getSpawnSkillPath("hermes")).toBe("~/.hermes/SOUL.md");
|
|
});
|
|
|
|
it("returns correct path for junie", () => {
|
|
expect(getSpawnSkillPath("junie")).toBe("~/.junie/AGENTS.md");
|
|
});
|
|
|
|
it("returns undefined for unknown agent", () => {
|
|
expect(getSpawnSkillPath("nonexistent")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── Append mode tests ──────────────────────────────────────────────────────
|
|
|
|
describe("isAppendMode", () => {
|
|
it("returns true for hermes", () => {
|
|
expect(isAppendMode("hermes")).toBe(true);
|
|
});
|
|
|
|
it("returns false for claude", () => {
|
|
expect(isAppendMode("claude")).toBe(false);
|
|
});
|
|
|
|
it("returns false for codex", () => {
|
|
expect(isAppendMode("codex")).toBe(false);
|
|
});
|
|
|
|
it("returns false for openclaw", () => {
|
|
expect(isAppendMode("openclaw")).toBe(false);
|
|
});
|
|
|
|
it("returns false for zeroclaw", () => {
|
|
expect(isAppendMode("zeroclaw")).toBe(false);
|
|
});
|
|
|
|
it("returns false for opencode", () => {
|
|
expect(isAppendMode("opencode")).toBe(false);
|
|
});
|
|
|
|
it("returns false for kilocode", () => {
|
|
expect(isAppendMode("kilocode")).toBe(false);
|
|
});
|
|
|
|
it("returns false for junie", () => {
|
|
expect(isAppendMode("junie")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── Embedded content tests ─────────────────────────────────────────────────
|
|
|
|
describe("getSkillContent", () => {
|
|
const agents = [
|
|
"claude",
|
|
"codex",
|
|
"openclaw",
|
|
"zeroclaw",
|
|
"opencode",
|
|
"kilocode",
|
|
"hermes",
|
|
"junie",
|
|
];
|
|
|
|
for (const agent of agents) {
|
|
it(`returns non-empty content for ${agent}`, () => {
|
|
const content = getSkillContent(agent);
|
|
expect(content).toBeDefined();
|
|
expect(content!.length).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
|
|
for (const agent of [
|
|
"claude",
|
|
"codex",
|
|
"openclaw",
|
|
]) {
|
|
it(`${agent} content has YAML frontmatter with name: spawn`, () => {
|
|
const content = getSkillContent(agent);
|
|
expect(content).toBeDefined();
|
|
expect(content!).toStartWith("---\n");
|
|
expect(content!).toContain("name: spawn");
|
|
});
|
|
}
|
|
|
|
for (const agent of [
|
|
"zeroclaw",
|
|
"opencode",
|
|
"kilocode",
|
|
"junie",
|
|
]) {
|
|
it(`${agent} content is plain markdown (no YAML frontmatter)`, () => {
|
|
const content = getSkillContent(agent);
|
|
expect(content).toBeDefined();
|
|
expect(content!).toStartWith("# Spawn");
|
|
});
|
|
}
|
|
|
|
it("hermes content is short append snippet", () => {
|
|
const content = getSkillContent("hermes");
|
|
expect(content).toBeDefined();
|
|
expect(content!).toContain("Spawn Capability");
|
|
expect(content!).not.toContain("# Spawn — Create Child VMs");
|
|
});
|
|
|
|
it("returns undefined for unknown agent", () => {
|
|
expect(getSkillContent("nonexistent")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── injectSpawnSkill tests ─────────────────────────────────────────────────
|
|
|
|
describe("injectSpawnSkill", () => {
|
|
it("calls runner.runServer with correct base64 + path for claude", async () => {
|
|
let capturedCmd = "";
|
|
const mockRunner = {
|
|
runServer: mock(async (cmd: string) => {
|
|
capturedCmd = cmd;
|
|
}),
|
|
uploadFile: mock(async () => {}),
|
|
};
|
|
|
|
await injectSpawnSkill(mockRunner, "claude");
|
|
|
|
expect(mockRunner.runServer).toHaveBeenCalledTimes(1);
|
|
expect(capturedCmd).toContain("~/.claude/skills/spawn/SKILL.md");
|
|
expect(capturedCmd).toContain("mkdir -p ~/.claude/skills/spawn");
|
|
expect(capturedCmd).toContain("base64 -d >");
|
|
expect(capturedCmd).toContain("chmod 644");
|
|
// Should use overwrite (>) not append (>>)
|
|
expect(capturedCmd).not.toContain(">>");
|
|
});
|
|
|
|
it("uses append mode (>>) for hermes", async () => {
|
|
let capturedCmd = "";
|
|
const mockRunner = {
|
|
runServer: mock(async (cmd: string) => {
|
|
capturedCmd = cmd;
|
|
}),
|
|
uploadFile: mock(async () => {}),
|
|
};
|
|
|
|
await injectSpawnSkill(mockRunner, "hermes");
|
|
|
|
expect(mockRunner.runServer).toHaveBeenCalledTimes(1);
|
|
expect(capturedCmd).toContain("~/.hermes/SOUL.md");
|
|
expect(capturedCmd).toContain(">>");
|
|
// Should NOT contain chmod for append mode
|
|
expect(capturedCmd).not.toContain("chmod");
|
|
});
|
|
|
|
it("creates parent directories for all agents", async () => {
|
|
const agents = [
|
|
"claude",
|
|
"codex",
|
|
"openclaw",
|
|
"zeroclaw",
|
|
"opencode",
|
|
"kilocode",
|
|
"hermes",
|
|
"junie",
|
|
];
|
|
for (const agent of agents) {
|
|
let capturedCmd = "";
|
|
const mockRunner = {
|
|
runServer: mock(async (cmd: string) => {
|
|
capturedCmd = cmd;
|
|
}),
|
|
uploadFile: mock(async () => {}),
|
|
};
|
|
|
|
await injectSpawnSkill(mockRunner, agent);
|
|
expect(capturedCmd).toContain("mkdir -p");
|
|
}
|
|
});
|
|
|
|
it("handles runner failure gracefully", async () => {
|
|
const mockRunner = {
|
|
runServer: mock(async () => {
|
|
throw new Error("SSH connection refused");
|
|
}),
|
|
uploadFile: mock(async () => {}),
|
|
};
|
|
|
|
// Should not throw
|
|
await injectSpawnSkill(mockRunner, "claude");
|
|
expect(mockRunner.runServer).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does nothing for unknown agent", async () => {
|
|
const mockRunner = {
|
|
runServer: mock(async () => {}),
|
|
uploadFile: mock(async () => {}),
|
|
};
|
|
|
|
await injectSpawnSkill(mockRunner, "nonexistent");
|
|
expect(mockRunner.runServer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("base64-encodes real skill content", async () => {
|
|
let capturedCmd = "";
|
|
const mockRunner = {
|
|
runServer: mock(async (cmd: string) => {
|
|
capturedCmd = cmd;
|
|
}),
|
|
uploadFile: mock(async () => {}),
|
|
};
|
|
|
|
await injectSpawnSkill(mockRunner, "codex");
|
|
|
|
// Extract the base64 string from the command
|
|
const b64Match = capturedCmd.match(/printf '%s' '([A-Za-z0-9+/=]+)'/);
|
|
expect(b64Match).not.toBeNull();
|
|
// Decode and verify it contains spawn skill content
|
|
const decoded = Buffer.from(b64Match![1], "base64").toString("utf-8");
|
|
expect(decoded).toContain("Spawn");
|
|
expect(decoded).toContain("spawn");
|
|
});
|
|
});
|
|
|
|
// ─── "spawn" step visibility tests ──────────────────────────────────────────
|
|
|
|
describe("spawn step gating", () => {
|
|
const savedBeta = process.env.SPAWN_BETA;
|
|
|
|
afterEach(() => {
|
|
if (savedBeta === undefined) {
|
|
delete process.env.SPAWN_BETA;
|
|
} else {
|
|
process.env.SPAWN_BETA = savedBeta;
|
|
}
|
|
});
|
|
|
|
it("spawn step appears when SPAWN_BETA includes recursive", async () => {
|
|
process.env.SPAWN_BETA = "recursive";
|
|
const { getAgentOptionalSteps } = await import("../shared/agents.js");
|
|
const steps = getAgentOptionalSteps("claude");
|
|
const spawnStep = steps.find((s) => s.value === "spawn");
|
|
expect(spawnStep).toBeDefined();
|
|
expect(spawnStep!.defaultOn).toBe(true);
|
|
});
|
|
|
|
it("spawn step does not appear without --beta recursive", async () => {
|
|
delete process.env.SPAWN_BETA;
|
|
const { getAgentOptionalSteps } = await import("../shared/agents.js");
|
|
const steps = getAgentOptionalSteps("claude");
|
|
const spawnStep = steps.find((s) => s.value === "spawn");
|
|
expect(spawnStep).toBeUndefined();
|
|
});
|
|
|
|
it("spawn step appears alongside other beta features", async () => {
|
|
process.env.SPAWN_BETA = "tarball,recursive";
|
|
const { getAgentOptionalSteps } = await import("../shared/agents.js");
|
|
const steps = getAgentOptionalSteps("openclaw");
|
|
const spawnStep = steps.find((s) => s.value === "spawn");
|
|
expect(spawnStep).toBeDefined();
|
|
});
|
|
});
|