skyloom 1.14.6 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +2 -2
- package/.github/workflows/publish.yml +74 -0
- package/CONVERSION_PLAN.md +191 -191
- package/README.md +523 -220
- package/config/default.yaml +46 -43
- package/config/models.yaml +928 -155
- package/config/providers.yaml +109 -6
- package/dist/agents/snow.d.ts +2 -0
- package/dist/agents/snow.d.ts.map +1 -1
- package/dist/agents/snow.js +36 -5
- package/dist/agents/snow.js.map +1 -1
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +207 -1
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/cli/main.js +190 -40
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/tui.d.ts.map +1 -1
- package/dist/cli/tui.js +6 -31
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/agent.d.ts +6 -4
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +61 -20
- package/dist/core/agent.js.map +1 -1
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +30 -9
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/commands.d.ts +110 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +633 -0
- package/dist/core/commands.js.map +1 -0
- package/dist/core/concurrency.d.ts +38 -0
- package/dist/core/concurrency.d.ts.map +1 -0
- package/dist/core/concurrency.js +65 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/factory.js +16 -16
- package/dist/core/file_checkpoint.d.ts +9 -0
- package/dist/core/file_checkpoint.d.ts.map +1 -1
- package/dist/core/file_checkpoint.js +33 -1
- package/dist/core/file_checkpoint.js.map +1 -1
- package/dist/core/llm.d.ts.map +1 -1
- package/dist/core/llm.js +66 -13
- package/dist/core/llm.js.map +1 -1
- package/dist/core/memory.js +51 -51
- package/dist/core/schemas.d.ts +16 -0
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +32 -0
- package/dist/core/schemas.js.map +1 -1
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +27 -0
- package/dist/core/security.js.map +1 -1
- package/dist/core/skymd.js +14 -14
- package/dist/core/trace.d.ts +105 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +213 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/tools/builtin.d.ts +2 -6
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +180 -125
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/extra.d.ts +13 -0
- package/dist/tools/extra.d.ts.map +1 -0
- package/dist/tools/extra.js +827 -0
- package/dist/tools/extra.js.map +1 -0
- package/dist/tools/guards.d.ts +12 -0
- package/dist/tools/guards.d.ts.map +1 -0
- package/dist/tools/guards.js +143 -0
- package/dist/tools/guards.js.map +1 -0
- package/dist/tools/model_tool.d.ts.map +1 -1
- package/dist/tools/model_tool.js +24 -4
- package/dist/tools/model_tool.js.map +1 -1
- package/dist/web/markdown.d.ts +32 -0
- package/dist/web/markdown.d.ts.map +1 -0
- package/dist/web/markdown.js +202 -0
- package/dist/web/markdown.js.map +1 -0
- package/dist/web/server.d.ts +4 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +14 -582
- package/dist/web/server.js.map +1 -1
- package/dist/web/ui.d.ts +31 -0
- package/dist/web/ui.d.ts.map +1 -0
- package/dist/web/ui.js +1009 -0
- package/dist/web/ui.js.map +1 -0
- package/docs/AESTHETIC_DESIGN.md +152 -152
- package/docs/OPTIMIZATION_PLAN.md +178 -178
- package/package.json +68 -68
- package/src/agents/snow.ts +38 -5
- package/src/cli/commands_md.ts +112 -112
- package/src/cli/input_macros.ts +83 -83
- package/src/cli/loom.ts +1041 -1041
- package/src/cli/loom_chat.ts +772 -603
- package/src/cli/main.ts +853 -723
- package/src/cli/tui.ts +264 -289
- package/src/core/agent/guard.ts +133 -133
- package/src/core/agent/task.ts +100 -100
- package/src/core/agent.ts +1630 -1590
- package/src/core/agent_helpers.ts +500 -500
- package/src/core/bus.ts +221 -221
- package/src/core/cache.ts +153 -153
- package/src/core/catalog.ts +199 -178
- package/src/core/circuit_breaker.ts +119 -119
- package/src/core/commands.ts +704 -0
- package/src/core/concurrency.ts +73 -0
- package/src/core/config.ts +365 -365
- package/src/core/constants.ts +95 -95
- package/src/core/factory.ts +656 -656
- package/src/core/file_checkpoint.ts +163 -136
- package/src/core/hooks.ts +126 -126
- package/src/core/llm.ts +972 -915
- package/src/core/logger.ts +143 -143
- package/src/core/mcp.ts +1001 -1001
- package/src/core/memory.ts +1201 -1201
- package/src/core/middleware.ts +350 -350
- package/src/core/model_config.ts +159 -159
- package/src/core/pipelines.ts +424 -424
- package/src/core/schemas.ts +319 -282
- package/src/core/security.ts +27 -0
- package/src/core/semantic.ts +211 -211
- package/src/core/skill.ts +384 -384
- package/src/core/skymd.ts +143 -143
- package/src/core/theme.ts +65 -65
- package/src/core/tool.ts +457 -457
- package/src/core/trace.ts +236 -0
- package/src/core/verify.ts +71 -71
- package/src/plugins/loader.ts +91 -91
- package/src/skills/loader.ts +75 -75
- package/src/tools/builtin.ts +571 -493
- package/src/tools/computer.ts +279 -279
- package/src/tools/extra.ts +662 -0
- package/src/tools/guards.ts +82 -0
- package/src/tools/model_tool.ts +93 -74
- package/src/tools/todo.ts +76 -76
- package/src/web/markdown.ts +193 -0
- package/src/web/server.ts +117 -693
- package/src/web/ui.ts +949 -0
- package/tests/agent.test.ts +211 -159
- package/tests/agent_helpers.test.ts +48 -48
- package/tests/catalog.test.ts +86 -86
- package/tests/checkpoint_commands.test.ts +124 -124
- package/tests/claude_compat.test.ts +110 -110
- package/tests/commands.test.ts +103 -0
- package/tests/concurrency.test.ts +102 -0
- package/tests/config.test.ts +41 -41
- package/tests/extra_tools.test.ts +212 -0
- package/tests/fence_plugin.test.ts +52 -52
- package/tests/guard.test.ts +75 -75
- package/tests/loom.test.ts +337 -337
- package/tests/memory.test.ts +170 -170
- package/tests/model_config.test.ts +109 -109
- package/tests/skymd.test.ts +146 -146
- package/tests/ssrf.test.ts +38 -38
- package/tests/structured_retry.test.ts +87 -0
- package/tests/task.test.ts +60 -60
- package/tests/todo_toolstats.test.ts +94 -94
- package/tests/trace.test.ts +128 -0
- package/tests/tui.test.ts +67 -67
- package/tests/web.test.ts +169 -0
- package/tsconfig.json +38 -38
package/tests/skymd.test.ts
CHANGED
|
@@ -1,146 +1,146 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import * as fs from "fs";
|
|
3
|
-
import * as os from "os";
|
|
4
|
-
import * as path from "path";
|
|
5
|
-
import { loadProjectMemory, appendQuickMemory, parseVerifyCommands, projectMemoryFile } from "../src/core/skymd";
|
|
6
|
-
import { resolveVerifyConfig, runVerify } from "../src/core/verify";
|
|
7
|
-
import { loadHooks, matches, runPreToolHooks } from "../src/core/hooks";
|
|
8
|
-
import { expandFileRefs, isBangCommand, bangCommand, runBang, isHashMemory, hashNote } from "../src/cli/input_macros";
|
|
9
|
-
|
|
10
|
-
let tmp: string;
|
|
11
|
-
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "skymd-")); });
|
|
12
|
-
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
13
|
-
|
|
14
|
-
describe("项目记忆 SKY.md", () => {
|
|
15
|
-
it("loads the project layer and reports contributing files", () => {
|
|
16
|
-
fs.writeFileSync(path.join(tmp, "SKY.md"), "构建: npm run build");
|
|
17
|
-
const mem = loadProjectMemory(tmp);
|
|
18
|
-
expect(mem.text).toContain("构建: npm run build");
|
|
19
|
-
expect(mem.files.some(f => f.endsWith("SKY.md"))).toBe(true);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("falls back to CLAUDE.md / AGENTS.md for compatibility", () => {
|
|
23
|
-
fs.writeFileSync(path.join(tmp, "AGENTS.md"), "agents 约定");
|
|
24
|
-
expect(projectMemoryFile(tmp)).toContain("AGENTS.md");
|
|
25
|
-
expect(loadProjectMemory(tmp).text).toContain("agents 约定");
|
|
26
|
-
// SKY.md wins over AGENTS.md when both exist
|
|
27
|
-
fs.writeFileSync(path.join(tmp, "SKY.md"), "sky 约定");
|
|
28
|
-
expect(projectMemoryFile(tmp)).toContain("SKY.md");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("layers SKY.local.md after the project file", () => {
|
|
32
|
-
fs.writeFileSync(path.join(tmp, "SKY.md"), "shared");
|
|
33
|
-
fs.writeFileSync(path.join(tmp, "SKY.local.md"), "personal");
|
|
34
|
-
const text = loadProjectMemory(tmp).text;
|
|
35
|
-
expect(text.indexOf("shared")).toBeLessThan(text.indexOf("personal"));
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("clamps oversized files", () => {
|
|
39
|
-
fs.writeFileSync(path.join(tmp, "SKY.md"), "x".repeat(50000));
|
|
40
|
-
expect(loadProjectMemory(tmp).text.length).toBeLessThan(15000);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("appendQuickMemory creates SKY.md with a header, then appends", () => {
|
|
44
|
-
const f1 = appendQuickMemory("测试必须用 vitest", tmp);
|
|
45
|
-
expect(fs.readFileSync(f1, "utf-8")).toContain("- 测试必须用 vitest");
|
|
46
|
-
appendQuickMemory("禁止 any", tmp);
|
|
47
|
-
const content = fs.readFileSync(f1, "utf-8");
|
|
48
|
-
expect(content).toContain("- 禁止 any");
|
|
49
|
-
expect(content.indexOf("vitest")).toBeLessThan(content.indexOf("禁止 any"));
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("appendQuickMemory targets an existing CLAUDE.md", () => {
|
|
53
|
-
fs.writeFileSync(path.join(tmp, "CLAUDE.md"), "# rules\n");
|
|
54
|
-
const f = appendQuickMemory("note", tmp);
|
|
55
|
-
expect(f).toContain("CLAUDE.md");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("parseVerifyCommands extracts the fenced block under ## Verify", () => {
|
|
59
|
-
const text = "# SKY\n\n## Verify\n```bash\nnpm test\n# comment\nnpm run lint\n```\n\n## Other\n";
|
|
60
|
-
expect(parseVerifyCommands(text)).toEqual(["npm test", "npm run lint"]);
|
|
61
|
-
expect(parseVerifyCommands("no section")).toEqual([]);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe("验证闭环", () => {
|
|
66
|
-
it("passes when every command exits 0", () => {
|
|
67
|
-
const r = runVerify({ commands: ["node -e \"process.exit(0)\""], maxFixRounds: 2, timeoutS: 30 }, tmp);
|
|
68
|
-
expect(r.ok).toBe(true);
|
|
69
|
-
expect(r.report).toContain("✓");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("stops at the first failure and captures output", () => {
|
|
73
|
-
const r = runVerify({
|
|
74
|
-
commands: ["node -e \"console.error('boom'); process.exit(3)\"", "node -e \"process.exit(0)\""],
|
|
75
|
-
maxFixRounds: 2, timeoutS: 30,
|
|
76
|
-
}, tmp);
|
|
77
|
-
expect(r.ok).toBe(false);
|
|
78
|
-
expect(r.report).toContain("boom");
|
|
79
|
-
expect(r.report).toContain("exit 3");
|
|
80
|
-
expect(r.report).not.toContain("✓ node -e \"process.exit(0)\"");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("resolveVerifyConfig prefers config over SKY.md", () => {
|
|
84
|
-
const cfg = resolveVerifyConfig({ verify: { commands: ["a"], max_fix_rounds: 5 } }, tmp);
|
|
85
|
-
expect(cfg.commands).toEqual(["a"]);
|
|
86
|
-
expect(cfg.maxFixRounds).toBe(5);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("hooks", () => {
|
|
91
|
-
it("loads and normalizes config shapes", () => {
|
|
92
|
-
const h = loadHooks({ hooks: { session_start: ["echo hi"], pre_tool: [{ matcher: "run_bash", command: "true" }], post_tool: ["echo done"] } });
|
|
93
|
-
expect(h.sessionStart).toEqual(["echo hi"]);
|
|
94
|
-
expect(h.preTool[0].matcher).toBe("run_bash");
|
|
95
|
-
expect(h.postTool[0].command).toBe("echo done");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("matcher is a regex on the tool name (missing matcher = all)", () => {
|
|
99
|
-
expect(matches({ matcher: "write_|edit_", command: "x" }, "write_file")).toBe(true);
|
|
100
|
-
expect(matches({ matcher: "write_|edit_", command: "x" }, "read_file")).toBe(false);
|
|
101
|
-
expect(matches({ command: "x" }, "anything")).toBe(true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("pre_tool hook blocks on non-zero exit", () => {
|
|
105
|
-
const h = loadHooks({ hooks: { pre_tool: [{ matcher: "danger", command: "node -e \"console.log('nope'); process.exit(1)\"" }] } });
|
|
106
|
-
const blocked = runPreToolHooks(h, "danger_tool", {}, "fog");
|
|
107
|
-
expect(blocked.allowed).toBe(false);
|
|
108
|
-
expect(blocked.reason).toContain("nope");
|
|
109
|
-
const allowed = runPreToolHooks(h, "read_file", {}, "fog");
|
|
110
|
-
expect(allowed.allowed).toBe(true);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("输入宏", () => {
|
|
115
|
-
it("# quick memory detection", () => {
|
|
116
|
-
expect(isHashMemory("# 用 pnpm")).toBe(true);
|
|
117
|
-
expect(isHashMemory("#用 pnpm")).toBe(true);
|
|
118
|
-
expect(isHashMemory("## markdown heading")).toBe(false);
|
|
119
|
-
expect(isHashMemory("正文 # 不算")).toBe(false);
|
|
120
|
-
expect(hashNote("# 用 pnpm ")).toBe("用 pnpm");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("! shell detection and execution", () => {
|
|
124
|
-
expect(isBangCommand("!git status")).toBe(true);
|
|
125
|
-
expect(isBangCommand("hello!")).toBe(false);
|
|
126
|
-
expect(bangCommand("! echo hi")).toBe("echo hi");
|
|
127
|
-
const r = runBang("node -e \"console.log('out')\"", tmp);
|
|
128
|
-
expect(r.ok).toBe(true);
|
|
129
|
-
expect(r.output).toBe("out");
|
|
130
|
-
expect(runBang("node -e \"process.exit(2)\"", tmp).ok).toBe(false);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("@file expands existing files into fenced attachments", () => {
|
|
134
|
-
fs.writeFileSync(path.join(tmp, "a.txt"), "file content here");
|
|
135
|
-
const r = expandFileRefs("看一下 @a.txt 的内容", tmp);
|
|
136
|
-
expect(r.attached).toEqual(["a.txt"]);
|
|
137
|
-
expect(r.text).toContain("file content here");
|
|
138
|
-
expect(r.text).toContain("看一下 @a.txt 的内容"); // original preserved
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("@file leaves missing files untouched", () => {
|
|
142
|
-
const r = expandFileRefs("ping @nonexistent.ts", tmp);
|
|
143
|
-
expect(r.attached).toEqual([]);
|
|
144
|
-
expect(r.text).toBe("ping @nonexistent.ts");
|
|
145
|
-
});
|
|
146
|
-
});
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { loadProjectMemory, appendQuickMemory, parseVerifyCommands, projectMemoryFile } from "../src/core/skymd";
|
|
6
|
+
import { resolveVerifyConfig, runVerify } from "../src/core/verify";
|
|
7
|
+
import { loadHooks, matches, runPreToolHooks } from "../src/core/hooks";
|
|
8
|
+
import { expandFileRefs, isBangCommand, bangCommand, runBang, isHashMemory, hashNote } from "../src/cli/input_macros";
|
|
9
|
+
|
|
10
|
+
let tmp: string;
|
|
11
|
+
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "skymd-")); });
|
|
12
|
+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
13
|
+
|
|
14
|
+
describe("项目记忆 SKY.md", () => {
|
|
15
|
+
it("loads the project layer and reports contributing files", () => {
|
|
16
|
+
fs.writeFileSync(path.join(tmp, "SKY.md"), "构建: npm run build");
|
|
17
|
+
const mem = loadProjectMemory(tmp);
|
|
18
|
+
expect(mem.text).toContain("构建: npm run build");
|
|
19
|
+
expect(mem.files.some(f => f.endsWith("SKY.md"))).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("falls back to CLAUDE.md / AGENTS.md for compatibility", () => {
|
|
23
|
+
fs.writeFileSync(path.join(tmp, "AGENTS.md"), "agents 约定");
|
|
24
|
+
expect(projectMemoryFile(tmp)).toContain("AGENTS.md");
|
|
25
|
+
expect(loadProjectMemory(tmp).text).toContain("agents 约定");
|
|
26
|
+
// SKY.md wins over AGENTS.md when both exist
|
|
27
|
+
fs.writeFileSync(path.join(tmp, "SKY.md"), "sky 约定");
|
|
28
|
+
expect(projectMemoryFile(tmp)).toContain("SKY.md");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("layers SKY.local.md after the project file", () => {
|
|
32
|
+
fs.writeFileSync(path.join(tmp, "SKY.md"), "shared");
|
|
33
|
+
fs.writeFileSync(path.join(tmp, "SKY.local.md"), "personal");
|
|
34
|
+
const text = loadProjectMemory(tmp).text;
|
|
35
|
+
expect(text.indexOf("shared")).toBeLessThan(text.indexOf("personal"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("clamps oversized files", () => {
|
|
39
|
+
fs.writeFileSync(path.join(tmp, "SKY.md"), "x".repeat(50000));
|
|
40
|
+
expect(loadProjectMemory(tmp).text.length).toBeLessThan(15000);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("appendQuickMemory creates SKY.md with a header, then appends", () => {
|
|
44
|
+
const f1 = appendQuickMemory("测试必须用 vitest", tmp);
|
|
45
|
+
expect(fs.readFileSync(f1, "utf-8")).toContain("- 测试必须用 vitest");
|
|
46
|
+
appendQuickMemory("禁止 any", tmp);
|
|
47
|
+
const content = fs.readFileSync(f1, "utf-8");
|
|
48
|
+
expect(content).toContain("- 禁止 any");
|
|
49
|
+
expect(content.indexOf("vitest")).toBeLessThan(content.indexOf("禁止 any"));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("appendQuickMemory targets an existing CLAUDE.md", () => {
|
|
53
|
+
fs.writeFileSync(path.join(tmp, "CLAUDE.md"), "# rules\n");
|
|
54
|
+
const f = appendQuickMemory("note", tmp);
|
|
55
|
+
expect(f).toContain("CLAUDE.md");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("parseVerifyCommands extracts the fenced block under ## Verify", () => {
|
|
59
|
+
const text = "# SKY\n\n## Verify\n```bash\nnpm test\n# comment\nnpm run lint\n```\n\n## Other\n";
|
|
60
|
+
expect(parseVerifyCommands(text)).toEqual(["npm test", "npm run lint"]);
|
|
61
|
+
expect(parseVerifyCommands("no section")).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("验证闭环", () => {
|
|
66
|
+
it("passes when every command exits 0", () => {
|
|
67
|
+
const r = runVerify({ commands: ["node -e \"process.exit(0)\""], maxFixRounds: 2, timeoutS: 30 }, tmp);
|
|
68
|
+
expect(r.ok).toBe(true);
|
|
69
|
+
expect(r.report).toContain("✓");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("stops at the first failure and captures output", () => {
|
|
73
|
+
const r = runVerify({
|
|
74
|
+
commands: ["node -e \"console.error('boom'); process.exit(3)\"", "node -e \"process.exit(0)\""],
|
|
75
|
+
maxFixRounds: 2, timeoutS: 30,
|
|
76
|
+
}, tmp);
|
|
77
|
+
expect(r.ok).toBe(false);
|
|
78
|
+
expect(r.report).toContain("boom");
|
|
79
|
+
expect(r.report).toContain("exit 3");
|
|
80
|
+
expect(r.report).not.toContain("✓ node -e \"process.exit(0)\"");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("resolveVerifyConfig prefers config over SKY.md", () => {
|
|
84
|
+
const cfg = resolveVerifyConfig({ verify: { commands: ["a"], max_fix_rounds: 5 } }, tmp);
|
|
85
|
+
expect(cfg.commands).toEqual(["a"]);
|
|
86
|
+
expect(cfg.maxFixRounds).toBe(5);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("hooks", () => {
|
|
91
|
+
it("loads and normalizes config shapes", () => {
|
|
92
|
+
const h = loadHooks({ hooks: { session_start: ["echo hi"], pre_tool: [{ matcher: "run_bash", command: "true" }], post_tool: ["echo done"] } });
|
|
93
|
+
expect(h.sessionStart).toEqual(["echo hi"]);
|
|
94
|
+
expect(h.preTool[0].matcher).toBe("run_bash");
|
|
95
|
+
expect(h.postTool[0].command).toBe("echo done");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("matcher is a regex on the tool name (missing matcher = all)", () => {
|
|
99
|
+
expect(matches({ matcher: "write_|edit_", command: "x" }, "write_file")).toBe(true);
|
|
100
|
+
expect(matches({ matcher: "write_|edit_", command: "x" }, "read_file")).toBe(false);
|
|
101
|
+
expect(matches({ command: "x" }, "anything")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("pre_tool hook blocks on non-zero exit", () => {
|
|
105
|
+
const h = loadHooks({ hooks: { pre_tool: [{ matcher: "danger", command: "node -e \"console.log('nope'); process.exit(1)\"" }] } });
|
|
106
|
+
const blocked = runPreToolHooks(h, "danger_tool", {}, "fog");
|
|
107
|
+
expect(blocked.allowed).toBe(false);
|
|
108
|
+
expect(blocked.reason).toContain("nope");
|
|
109
|
+
const allowed = runPreToolHooks(h, "read_file", {}, "fog");
|
|
110
|
+
expect(allowed.allowed).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("输入宏", () => {
|
|
115
|
+
it("# quick memory detection", () => {
|
|
116
|
+
expect(isHashMemory("# 用 pnpm")).toBe(true);
|
|
117
|
+
expect(isHashMemory("#用 pnpm")).toBe(true);
|
|
118
|
+
expect(isHashMemory("## markdown heading")).toBe(false);
|
|
119
|
+
expect(isHashMemory("正文 # 不算")).toBe(false);
|
|
120
|
+
expect(hashNote("# 用 pnpm ")).toBe("用 pnpm");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("! shell detection and execution", () => {
|
|
124
|
+
expect(isBangCommand("!git status")).toBe(true);
|
|
125
|
+
expect(isBangCommand("hello!")).toBe(false);
|
|
126
|
+
expect(bangCommand("! echo hi")).toBe("echo hi");
|
|
127
|
+
const r = runBang("node -e \"console.log('out')\"", tmp);
|
|
128
|
+
expect(r.ok).toBe(true);
|
|
129
|
+
expect(r.output).toBe("out");
|
|
130
|
+
expect(runBang("node -e \"process.exit(2)\"", tmp).ok).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("@file expands existing files into fenced attachments", () => {
|
|
134
|
+
fs.writeFileSync(path.join(tmp, "a.txt"), "file content here");
|
|
135
|
+
const r = expandFileRefs("看一下 @a.txt 的内容", tmp);
|
|
136
|
+
expect(r.attached).toEqual(["a.txt"]);
|
|
137
|
+
expect(r.text).toContain("file content here");
|
|
138
|
+
expect(r.text).toContain("看一下 @a.txt 的内容"); // original preserved
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("@file leaves missing files untouched", () => {
|
|
142
|
+
const r = expandFileRefs("ping @nonexistent.ts", tmp);
|
|
143
|
+
expect(r.attached).toEqual([]);
|
|
144
|
+
expect(r.text).toBe("ping @nonexistent.ts");
|
|
145
|
+
});
|
|
146
|
+
});
|
package/tests/ssrf.test.ts
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
-
import { isPrivateIp, assertFetchAllowed } from "../src/tools/builtin";
|
|
3
|
-
|
|
4
|
-
describe("SSRF guard — isPrivateIp", () => {
|
|
5
|
-
it("flags loopback, private, link-local and metadata addresses", () => {
|
|
6
|
-
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.3.4", "172.31.255.255",
|
|
7
|
-
"192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0",
|
|
8
|
-
"::1", "::", "fc00::1", "fd12::3", "fe80::1", "::ffff:127.0.0.1"]) {
|
|
9
|
-
expect(isPrivateIp(ip), ip).toBe(true);
|
|
10
|
-
}
|
|
11
|
-
});
|
|
12
|
-
it("allows public addresses", () => {
|
|
13
|
-
for (const ip of ["8.8.8.8", "1.1.1.1", "172.32.0.1", "192.169.0.1", "2606:4700::1"]) {
|
|
14
|
-
expect(isPrivateIp(ip), ip).toBe(false);
|
|
15
|
-
}
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe("SSRF guard — assertFetchAllowed", () => {
|
|
20
|
-
afterEach(() => { delete process.env.SKYLOOM_ALLOW_PRIVATE_FETCH; });
|
|
21
|
-
|
|
22
|
-
it("rejects non-http(s) schemes", async () => {
|
|
23
|
-
await expect(assertFetchAllowed("file:///etc/passwd")).rejects.toThrow(/scheme/);
|
|
24
|
-
await expect(assertFetchAllowed("gopher://x")).rejects.toThrow(/scheme/);
|
|
25
|
-
});
|
|
26
|
-
it("rejects IP-literal private targets", async () => {
|
|
27
|
-
await expect(assertFetchAllowed("http://169.254.169.254/latest/meta-data/")).rejects.toThrow(/private/);
|
|
28
|
-
await expect(assertFetchAllowed("http://127.0.0.1:6379/")).rejects.toThrow(/private/);
|
|
29
|
-
await expect(assertFetchAllowed("http://[::1]/")).rejects.toThrow(/private/);
|
|
30
|
-
});
|
|
31
|
-
it("honors the opt-out env var", async () => {
|
|
32
|
-
process.env.SKYLOOM_ALLOW_PRIVATE_FETCH = "1";
|
|
33
|
-
await expect(assertFetchAllowed("http://127.0.0.1/")).resolves.toBeUndefined();
|
|
34
|
-
});
|
|
35
|
-
it("rejects an invalid URL", async () => {
|
|
36
|
-
await expect(assertFetchAllowed("not a url")).rejects.toThrow(/invalid URL/);
|
|
37
|
-
});
|
|
38
|
-
});
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { isPrivateIp, assertFetchAllowed } from "../src/tools/builtin";
|
|
3
|
+
|
|
4
|
+
describe("SSRF guard — isPrivateIp", () => {
|
|
5
|
+
it("flags loopback, private, link-local and metadata addresses", () => {
|
|
6
|
+
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.3.4", "172.31.255.255",
|
|
7
|
+
"192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0",
|
|
8
|
+
"::1", "::", "fc00::1", "fd12::3", "fe80::1", "::ffff:127.0.0.1"]) {
|
|
9
|
+
expect(isPrivateIp(ip), ip).toBe(true);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
it("allows public addresses", () => {
|
|
13
|
+
for (const ip of ["8.8.8.8", "1.1.1.1", "172.32.0.1", "192.169.0.1", "2606:4700::1"]) {
|
|
14
|
+
expect(isPrivateIp(ip), ip).toBe(false);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("SSRF guard — assertFetchAllowed", () => {
|
|
20
|
+
afterEach(() => { delete process.env.SKYLOOM_ALLOW_PRIVATE_FETCH; });
|
|
21
|
+
|
|
22
|
+
it("rejects non-http(s) schemes", async () => {
|
|
23
|
+
await expect(assertFetchAllowed("file:///etc/passwd")).rejects.toThrow(/scheme/);
|
|
24
|
+
await expect(assertFetchAllowed("gopher://x")).rejects.toThrow(/scheme/);
|
|
25
|
+
});
|
|
26
|
+
it("rejects IP-literal private targets", async () => {
|
|
27
|
+
await expect(assertFetchAllowed("http://169.254.169.254/latest/meta-data/")).rejects.toThrow(/private/);
|
|
28
|
+
await expect(assertFetchAllowed("http://127.0.0.1:6379/")).rejects.toThrow(/private/);
|
|
29
|
+
await expect(assertFetchAllowed("http://[::1]/")).rejects.toThrow(/private/);
|
|
30
|
+
});
|
|
31
|
+
it("honors the opt-out env var", async () => {
|
|
32
|
+
process.env.SKYLOOM_ALLOW_PRIVATE_FETCH = "1";
|
|
33
|
+
await expect(assertFetchAllowed("http://127.0.0.1/")).resolves.toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
it("rejects an invalid URL", async () => {
|
|
36
|
+
await expect(assertFetchAllowed("not a url")).rejects.toThrow(/invalid URL/);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseWithRetry, SchemaValidationError } from "../src/core/schemas";
|
|
3
|
+
import { SnowAgent } from "../src/agents/snow";
|
|
4
|
+
import { MessageBus } from "../src/core/bus";
|
|
5
|
+
import { ToolRegistry } from "../src/core/tool";
|
|
6
|
+
import { SkillRegistry } from "../src/core/skill";
|
|
7
|
+
|
|
8
|
+
describe("parseWithRetry", () => {
|
|
9
|
+
it("returns the first valid parse without retrying", async () => {
|
|
10
|
+
let asks = 0;
|
|
11
|
+
const v = await parseWithRetry(
|
|
12
|
+
async () => { asks++; return '{"ok":1}'; },
|
|
13
|
+
(raw) => JSON.parse(raw),
|
|
14
|
+
);
|
|
15
|
+
expect(v).toEqual({ ok: 1 });
|
|
16
|
+
expect(asks).toBe(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("feeds the parse error back and succeeds on a later attempt", async () => {
|
|
20
|
+
const replies = ["not json", "still bad", '{"ok":1}'];
|
|
21
|
+
const errors: string[] = [];
|
|
22
|
+
let lastPrior: string | null = "sentinel";
|
|
23
|
+
const v = await parseWithRetry(
|
|
24
|
+
async (priorError, attempt) => { lastPrior = priorError; return replies[attempt]; },
|
|
25
|
+
(raw) => { const d = JSON.parse(raw); return d; },
|
|
26
|
+
{ retries: 2, onRetry: (_a, e) => errors.push(e) },
|
|
27
|
+
);
|
|
28
|
+
expect(v).toEqual({ ok: 1 });
|
|
29
|
+
expect(errors).toHaveLength(2); // two failures before success
|
|
30
|
+
expect(lastPrior).not.toBeNull(); // third attempt received the prior error
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("throws after exhausting retries", async () => {
|
|
34
|
+
await expect(parseWithRetry(
|
|
35
|
+
async () => "nope",
|
|
36
|
+
(raw) => JSON.parse(raw),
|
|
37
|
+
{ retries: 1 },
|
|
38
|
+
)).rejects.toBeInstanceOf(SchemaValidationError);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Mock LLM that yields scripted contents across successive llmLoop calls. */
|
|
43
|
+
class ScriptedLLM {
|
|
44
|
+
calls = 0;
|
|
45
|
+
constructor(private contents: string[]) {}
|
|
46
|
+
async *streamWithTools(): AsyncGenerator<any> {
|
|
47
|
+
const c = this.contents[Math.min(this.calls, this.contents.length - 1)];
|
|
48
|
+
this.calls++;
|
|
49
|
+
yield { type: "content", text: c };
|
|
50
|
+
yield { type: "done", usage: { promptTokens: 1, completionTokens: 1 } };
|
|
51
|
+
}
|
|
52
|
+
async complete(): Promise<any> {
|
|
53
|
+
const c = this.contents[Math.min(this.calls, this.contents.length - 1)];
|
|
54
|
+
this.calls++;
|
|
55
|
+
return { content: c, toolCalls: [], model: "mock", usage: { promptTokens: 1, completionTokens: 1 }, cost: 0, truncated: false };
|
|
56
|
+
}
|
|
57
|
+
getTotalCost() { return 0; }
|
|
58
|
+
getModel() { return "mock"; }
|
|
59
|
+
setLogger() { /* noop */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeSnow(contents: string[]) {
|
|
63
|
+
const config = { agents: { snow: {} }, llm: { language: "zh" }, memory: { shortTermLimit: 100, dbPath: "/tmp/sky-test-snow" } };
|
|
64
|
+
return new SnowAgent(config as any, new ScriptedLLM(contents) as any, new MessageBus(), new ToolRegistry(), new SkillRegistry());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("snow.orchestrate · structured-output retry", () => {
|
|
68
|
+
const goodPlan = JSON.stringify({ goal: "g", steps: [
|
|
69
|
+
{ id: "1", description: "research", agent: "fog" },
|
|
70
|
+
{ id: "2", description: "code", agent: "rain" },
|
|
71
|
+
]});
|
|
72
|
+
|
|
73
|
+
it("recovers a multi-step plan after a malformed first response", async () => {
|
|
74
|
+
const snow = makeSnow(["这是我的计划:不是 JSON", goodPlan]);
|
|
75
|
+
const tasks = await snow.orchestrate("build X");
|
|
76
|
+
expect(tasks).toHaveLength(2);
|
|
77
|
+
expect(tasks.map((t) => t.assignedTo)).toEqual(["fog", "rain"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("falls back to a single task only after all retries fail", async () => {
|
|
81
|
+
const snow = makeSnow(["garbage", "still garbage", "nope not json either", "and again"]);
|
|
82
|
+
const tasks = await snow.orchestrate("build Y");
|
|
83
|
+
expect(tasks).toHaveLength(1);
|
|
84
|
+
expect(tasks[0].assignedTo).toBe("rain");
|
|
85
|
+
expect(tasks[0].description).toBe("build Y");
|
|
86
|
+
});
|
|
87
|
+
});
|
package/tests/task.test.ts
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { Task, TaskState, TaskResult, VALID_TRANSITIONS } from "../src/core/agent/task";
|
|
3
|
-
// Also assert the re-export path stays stable for external importers.
|
|
4
|
-
import { Task as TaskViaAgent, TaskState as TaskStateViaAgent } from "../src/core/agent";
|
|
5
|
-
|
|
6
|
-
describe("Task domain model", () => {
|
|
7
|
-
it("applies sensible defaults", () => {
|
|
8
|
-
const t = new Task({ id: "1", description: "do a thing" });
|
|
9
|
-
expect(t.status).toBe(TaskState.PENDING);
|
|
10
|
-
expect(t.assignedTo).toBeNull();
|
|
11
|
-
expect(t.dependsOn).toEqual([]);
|
|
12
|
-
expect(t.priority).toBe(0);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("allows PENDING -> RUNNING -> COMPLETED", () => {
|
|
16
|
-
const t = new Task({ id: "1", description: "x" });
|
|
17
|
-
t.transitionTo(TaskState.RUNNING);
|
|
18
|
-
expect(t.status).toBe(TaskState.RUNNING);
|
|
19
|
-
t.transitionTo(TaskState.COMPLETED);
|
|
20
|
-
expect(t.status).toBe(TaskState.COMPLETED);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("rejects illegal transitions", () => {
|
|
24
|
-
const t = new Task({ id: "1", description: "x" });
|
|
25
|
-
// PENDING -> COMPLETED is not allowed (must go through RUNNING)
|
|
26
|
-
expect(() => t.transitionTo(TaskState.COMPLETED)).toThrow(/Invalid task state transition/);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("treats COMPLETED as terminal", () => {
|
|
30
|
-
expect(VALID_TRANSITIONS[TaskState.COMPLETED].size).toBe(0);
|
|
31
|
-
expect(VALID_TRANSITIONS[TaskState.SKIPPED].size).toBe(0);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("allows FAILED -> RUNNING (retry) and FAILED -> SKIPPED", () => {
|
|
35
|
-
const t = new Task({ id: "1", description: "x", status: TaskState.FAILED });
|
|
36
|
-
expect(() => t.transitionTo(TaskState.RUNNING)).not.toThrow();
|
|
37
|
-
const t2 = new Task({ id: "2", description: "y", status: TaskState.FAILED });
|
|
38
|
-
expect(() => t2.transitionTo(TaskState.SKIPPED)).not.toThrow();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("allDeps merges parentId with dependsOn without duplicates", () => {
|
|
42
|
-
const t = new Task({ id: "3", description: "z", parentId: "1", dependsOn: ["1", "2"] });
|
|
43
|
-
expect(t.allDeps.sort()).toEqual(["1", "2"]);
|
|
44
|
-
const t2 = new Task({ id: "4", description: "z", parentId: "9", dependsOn: ["2"] });
|
|
45
|
-
expect(t2.allDeps.sort()).toEqual(["2", "9"]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("TaskResult carries success/content/data", () => {
|
|
49
|
-
const ok = new TaskResult(true, "done", { x: 1 });
|
|
50
|
-
expect(ok.success).toBe(true);
|
|
51
|
-
expect(ok.content).toBe("done");
|
|
52
|
-
expect(ok.data).toEqual({ x: 1 });
|
|
53
|
-
expect(new TaskResult(false, "oops").data).toEqual({});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("re-export from ../core/agent stays identical", () => {
|
|
57
|
-
expect(TaskViaAgent).toBe(Task);
|
|
58
|
-
expect(TaskStateViaAgent).toBe(TaskState);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Task, TaskState, TaskResult, VALID_TRANSITIONS } from "../src/core/agent/task";
|
|
3
|
+
// Also assert the re-export path stays stable for external importers.
|
|
4
|
+
import { Task as TaskViaAgent, TaskState as TaskStateViaAgent } from "../src/core/agent";
|
|
5
|
+
|
|
6
|
+
describe("Task domain model", () => {
|
|
7
|
+
it("applies sensible defaults", () => {
|
|
8
|
+
const t = new Task({ id: "1", description: "do a thing" });
|
|
9
|
+
expect(t.status).toBe(TaskState.PENDING);
|
|
10
|
+
expect(t.assignedTo).toBeNull();
|
|
11
|
+
expect(t.dependsOn).toEqual([]);
|
|
12
|
+
expect(t.priority).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("allows PENDING -> RUNNING -> COMPLETED", () => {
|
|
16
|
+
const t = new Task({ id: "1", description: "x" });
|
|
17
|
+
t.transitionTo(TaskState.RUNNING);
|
|
18
|
+
expect(t.status).toBe(TaskState.RUNNING);
|
|
19
|
+
t.transitionTo(TaskState.COMPLETED);
|
|
20
|
+
expect(t.status).toBe(TaskState.COMPLETED);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("rejects illegal transitions", () => {
|
|
24
|
+
const t = new Task({ id: "1", description: "x" });
|
|
25
|
+
// PENDING -> COMPLETED is not allowed (must go through RUNNING)
|
|
26
|
+
expect(() => t.transitionTo(TaskState.COMPLETED)).toThrow(/Invalid task state transition/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("treats COMPLETED as terminal", () => {
|
|
30
|
+
expect(VALID_TRANSITIONS[TaskState.COMPLETED].size).toBe(0);
|
|
31
|
+
expect(VALID_TRANSITIONS[TaskState.SKIPPED].size).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("allows FAILED -> RUNNING (retry) and FAILED -> SKIPPED", () => {
|
|
35
|
+
const t = new Task({ id: "1", description: "x", status: TaskState.FAILED });
|
|
36
|
+
expect(() => t.transitionTo(TaskState.RUNNING)).not.toThrow();
|
|
37
|
+
const t2 = new Task({ id: "2", description: "y", status: TaskState.FAILED });
|
|
38
|
+
expect(() => t2.transitionTo(TaskState.SKIPPED)).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("allDeps merges parentId with dependsOn without duplicates", () => {
|
|
42
|
+
const t = new Task({ id: "3", description: "z", parentId: "1", dependsOn: ["1", "2"] });
|
|
43
|
+
expect(t.allDeps.sort()).toEqual(["1", "2"]);
|
|
44
|
+
const t2 = new Task({ id: "4", description: "z", parentId: "9", dependsOn: ["2"] });
|
|
45
|
+
expect(t2.allDeps.sort()).toEqual(["2", "9"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("TaskResult carries success/content/data", () => {
|
|
49
|
+
const ok = new TaskResult(true, "done", { x: 1 });
|
|
50
|
+
expect(ok.success).toBe(true);
|
|
51
|
+
expect(ok.content).toBe("done");
|
|
52
|
+
expect(ok.data).toEqual({ x: 1 });
|
|
53
|
+
expect(new TaskResult(false, "oops").data).toEqual({});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("re-export from ../core/agent stays identical", () => {
|
|
57
|
+
expect(TaskViaAgent).toBe(Task);
|
|
58
|
+
expect(TaskStateViaAgent).toBe(TaskState);
|
|
59
|
+
});
|
|
60
|
+
});
|