Files
triggerdotdev--trigger.dev/packages/trigger-sdk/test/skillsRuntime.test.ts
T
2026-07-13 13:32:57 +08:00

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