222 lines
7.2 KiB
TypeScript
222 lines
7.2 KiB
TypeScript
// Import the test harness FIRST so the resource catalog is installed
|
|
import { mockChatAgent } from "../src/v3/test/index.js";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { mkdtemp, mkdir, realpath, writeFile, rm, chmod } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import * as path from "node:path";
|
|
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
|
|
import { MockLanguageModelV3 } from "ai/test";
|
|
import { simulateReadableStream, streamText } from "ai";
|
|
import { buildSkillTools, chat } from "../src/v3/ai.js";
|
|
import { defineSkill } from "../src/v3/skill.js";
|
|
|
|
function userMessage(text: string, id?: string) {
|
|
return {
|
|
id: id ?? `u-${Math.random().toString(36).slice(2)}`,
|
|
role: "user" as const,
|
|
parts: [{ type: "text" as const, text }],
|
|
};
|
|
}
|
|
|
|
function textStream(text: string) {
|
|
const chunks: LanguageModelV3StreamPart[] = [
|
|
{ type: "text-start", id: "t1" },
|
|
{ type: "text-delta", id: "t1", delta: text },
|
|
{ type: "text-end", id: "t1" },
|
|
{
|
|
type: "finish",
|
|
finishReason: { unified: "stop", raw: "stop" },
|
|
usage: {
|
|
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
|
|
outputTokens: { total: 10, text: 10, reasoning: undefined },
|
|
},
|
|
},
|
|
];
|
|
return simulateReadableStream({ chunks });
|
|
}
|
|
|
|
const originalCwd = process.cwd();
|
|
let workdir: string;
|
|
|
|
beforeEach(async () => {
|
|
workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skills-runtime-")));
|
|
process.chdir(workdir);
|
|
|
|
// Bundled skill layout
|
|
const skillDir = path.join(workdir, ".trigger", "skills", "demo");
|
|
await mkdir(path.join(skillDir, "scripts"), { recursive: true });
|
|
await mkdir(path.join(skillDir, "references"), { recursive: true });
|
|
|
|
await writeFile(
|
|
path.join(skillDir, "SKILL.md"),
|
|
`---\nname: demo\ndescription: Demo skill for tests.\n---\n\n# Demo\n\nUse scripts/hello.sh to say hello.\n`
|
|
);
|
|
|
|
const scriptPath = path.join(skillDir, "scripts", "hello.sh");
|
|
await writeFile(scriptPath, `#!/usr/bin/env bash\necho "hi from $1"\n`);
|
|
await chmod(scriptPath, 0o755);
|
|
|
|
await writeFile(path.join(skillDir, "references", "notes.txt"), "Reference note.\n");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
process.chdir(originalCwd);
|
|
await rm(workdir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("chat.skills runtime integration", () => {
|
|
it("injects skills preamble into the system prompt", async () => {
|
|
let capturedSystem: string | undefined;
|
|
|
|
const model = new MockLanguageModelV3({
|
|
doStream: async (opts) => {
|
|
const system = opts.prompt.find((m) => m.role === "system");
|
|
capturedSystem = system ? JSON.stringify(system.content) : undefined;
|
|
return { stream: textStream("ok") };
|
|
},
|
|
});
|
|
|
|
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
|
|
|
const agent = chat.agent({
|
|
id: "skills-runtime.system-prompt",
|
|
onChatStart: async () => {
|
|
chat.skills.set([await skill.local()]);
|
|
},
|
|
run: async ({ messages, signal }) => {
|
|
return streamText({
|
|
model,
|
|
messages,
|
|
abortSignal: signal,
|
|
...chat.toStreamTextOptions(),
|
|
});
|
|
},
|
|
});
|
|
|
|
const harness = mockChatAgent(agent, { chatId: "t1" });
|
|
try {
|
|
await harness.sendMessage(userMessage("hi"));
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(capturedSystem).toContain("Available skills");
|
|
expect(capturedSystem).toContain("demo: Demo skill for tests");
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it("auto-wires loadSkill / readFile / bash tools", async () => {
|
|
let capturedToolNames: string[] = [];
|
|
|
|
const model = new MockLanguageModelV3({
|
|
doStream: async (opts) => {
|
|
capturedToolNames = (opts.tools ?? []).map((t) => t.name);
|
|
return { stream: textStream("ok") };
|
|
},
|
|
});
|
|
|
|
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
|
|
|
const agent = chat.agent({
|
|
id: "skills-runtime.auto-tools",
|
|
onChatStart: async () => {
|
|
chat.skills.set([await skill.local()]);
|
|
},
|
|
run: async ({ messages, signal }) => {
|
|
return streamText({
|
|
model,
|
|
messages,
|
|
abortSignal: signal,
|
|
...chat.toStreamTextOptions(),
|
|
});
|
|
},
|
|
});
|
|
|
|
const harness = mockChatAgent(agent, { chatId: "t2" });
|
|
try {
|
|
await harness.sendMessage(userMessage("hi"));
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(capturedToolNames).toEqual(expect.arrayContaining(["loadSkill", "readFile", "bash"]));
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("buildSkillTools — direct execute", () => {
|
|
it("loadSkill returns body + path for a known skill", async () => {
|
|
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
|
const resolved = await skill.local();
|
|
const tools = buildSkillTools([resolved]);
|
|
|
|
const out = await (tools.loadSkill as any).execute({ name: "demo" });
|
|
expect(out.name).toBe("demo");
|
|
expect(out.body).toContain("# Demo");
|
|
expect(out.path).toBe(resolved.path);
|
|
});
|
|
|
|
it("loadSkill returns an error for an unknown skill", async () => {
|
|
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
|
const tools = buildSkillTools([await skill.local()]);
|
|
|
|
const out = await (tools.loadSkill as any).execute({ name: "missing" });
|
|
expect(out.error).toContain('Skill "missing" not found');
|
|
});
|
|
|
|
it("readFile reads a bundled reference", async () => {
|
|
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
|
const tools = buildSkillTools([await skill.local()]);
|
|
|
|
const out = await (tools.readFile as any).execute({
|
|
skill: "demo",
|
|
path: "references/notes.txt",
|
|
});
|
|
expect(out.content).toBe("Reference note.\n");
|
|
});
|
|
|
|
it("readFile rejects path traversal", async () => {
|
|
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
|
const tools = buildSkillTools([await skill.local()]);
|
|
|
|
const out = await (tools.readFile as any).execute({
|
|
skill: "demo",
|
|
path: "../../../../etc/passwd",
|
|
});
|
|
expect(out.error).toMatch(/escapes the skill directory/);
|
|
});
|
|
|
|
it("readFile rejects absolute paths", async () => {
|
|
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
|
const tools = buildSkillTools([await skill.local()]);
|
|
|
|
const out = await (tools.readFile as any).execute({
|
|
skill: "demo",
|
|
path: "/etc/passwd",
|
|
});
|
|
expect(out.error).toMatch(/must be relative/);
|
|
});
|
|
|
|
it("bash runs a bundled script and captures stdout", async () => {
|
|
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
|
const tools = buildSkillTools([await skill.local()]);
|
|
|
|
const out = await (tools.bash as any).execute({
|
|
skill: "demo",
|
|
command: "bash scripts/hello.sh world",
|
|
});
|
|
expect(out.exitCode).toBe(0);
|
|
expect(out.stdout).toContain("hi from world");
|
|
});
|
|
|
|
it("bash reports non-zero exit code", async () => {
|
|
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
|
|
const tools = buildSkillTools([await skill.local()]);
|
|
|
|
const out = await (tools.bash as any).execute({
|
|
skill: "demo",
|
|
command: "exit 7",
|
|
});
|
|
expect(out.exitCode).toBe(7);
|
|
});
|
|
});
|