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.
Files changed (157) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/publish.yml +74 -0
  3. package/CONVERSION_PLAN.md +191 -191
  4. package/README.md +523 -220
  5. package/config/default.yaml +46 -43
  6. package/config/models.yaml +928 -155
  7. package/config/providers.yaml +109 -6
  8. package/dist/agents/snow.d.ts +2 -0
  9. package/dist/agents/snow.d.ts.map +1 -1
  10. package/dist/agents/snow.js +36 -5
  11. package/dist/agents/snow.js.map +1 -1
  12. package/dist/cli/loom_chat.d.ts.map +1 -1
  13. package/dist/cli/loom_chat.js +207 -1
  14. package/dist/cli/loom_chat.js.map +1 -1
  15. package/dist/cli/main.js +190 -40
  16. package/dist/cli/main.js.map +1 -1
  17. package/dist/cli/tui.d.ts.map +1 -1
  18. package/dist/cli/tui.js +6 -31
  19. package/dist/cli/tui.js.map +1 -1
  20. package/dist/core/agent.d.ts +6 -4
  21. package/dist/core/agent.d.ts.map +1 -1
  22. package/dist/core/agent.js +61 -20
  23. package/dist/core/agent.js.map +1 -1
  24. package/dist/core/catalog.d.ts.map +1 -1
  25. package/dist/core/catalog.js +30 -9
  26. package/dist/core/catalog.js.map +1 -1
  27. package/dist/core/commands.d.ts +110 -0
  28. package/dist/core/commands.d.ts.map +1 -0
  29. package/dist/core/commands.js +633 -0
  30. package/dist/core/commands.js.map +1 -0
  31. package/dist/core/concurrency.d.ts +38 -0
  32. package/dist/core/concurrency.d.ts.map +1 -0
  33. package/dist/core/concurrency.js +65 -0
  34. package/dist/core/concurrency.js.map +1 -0
  35. package/dist/core/factory.js +16 -16
  36. package/dist/core/file_checkpoint.d.ts +9 -0
  37. package/dist/core/file_checkpoint.d.ts.map +1 -1
  38. package/dist/core/file_checkpoint.js +33 -1
  39. package/dist/core/file_checkpoint.js.map +1 -1
  40. package/dist/core/llm.d.ts.map +1 -1
  41. package/dist/core/llm.js +66 -13
  42. package/dist/core/llm.js.map +1 -1
  43. package/dist/core/memory.js +51 -51
  44. package/dist/core/schemas.d.ts +16 -0
  45. package/dist/core/schemas.d.ts.map +1 -1
  46. package/dist/core/schemas.js +32 -0
  47. package/dist/core/schemas.js.map +1 -1
  48. package/dist/core/security.d.ts.map +1 -1
  49. package/dist/core/security.js +27 -0
  50. package/dist/core/security.js.map +1 -1
  51. package/dist/core/skymd.js +14 -14
  52. package/dist/core/trace.d.ts +105 -0
  53. package/dist/core/trace.d.ts.map +1 -0
  54. package/dist/core/trace.js +213 -0
  55. package/dist/core/trace.js.map +1 -0
  56. package/dist/tools/builtin.d.ts +2 -6
  57. package/dist/tools/builtin.d.ts.map +1 -1
  58. package/dist/tools/builtin.js +180 -125
  59. package/dist/tools/builtin.js.map +1 -1
  60. package/dist/tools/extra.d.ts +13 -0
  61. package/dist/tools/extra.d.ts.map +1 -0
  62. package/dist/tools/extra.js +827 -0
  63. package/dist/tools/extra.js.map +1 -0
  64. package/dist/tools/guards.d.ts +12 -0
  65. package/dist/tools/guards.d.ts.map +1 -0
  66. package/dist/tools/guards.js +143 -0
  67. package/dist/tools/guards.js.map +1 -0
  68. package/dist/tools/model_tool.d.ts.map +1 -1
  69. package/dist/tools/model_tool.js +24 -4
  70. package/dist/tools/model_tool.js.map +1 -1
  71. package/dist/web/markdown.d.ts +32 -0
  72. package/dist/web/markdown.d.ts.map +1 -0
  73. package/dist/web/markdown.js +202 -0
  74. package/dist/web/markdown.js.map +1 -0
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +14 -582
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/ui.d.ts +31 -0
  80. package/dist/web/ui.d.ts.map +1 -0
  81. package/dist/web/ui.js +1009 -0
  82. package/dist/web/ui.js.map +1 -0
  83. package/docs/AESTHETIC_DESIGN.md +152 -152
  84. package/docs/OPTIMIZATION_PLAN.md +178 -178
  85. package/package.json +68 -68
  86. package/src/agents/snow.ts +38 -5
  87. package/src/cli/commands_md.ts +112 -112
  88. package/src/cli/input_macros.ts +83 -83
  89. package/src/cli/loom.ts +1041 -1041
  90. package/src/cli/loom_chat.ts +772 -603
  91. package/src/cli/main.ts +853 -723
  92. package/src/cli/tui.ts +264 -289
  93. package/src/core/agent/guard.ts +133 -133
  94. package/src/core/agent/task.ts +100 -100
  95. package/src/core/agent.ts +1630 -1590
  96. package/src/core/agent_helpers.ts +500 -500
  97. package/src/core/bus.ts +221 -221
  98. package/src/core/cache.ts +153 -153
  99. package/src/core/catalog.ts +199 -178
  100. package/src/core/circuit_breaker.ts +119 -119
  101. package/src/core/commands.ts +704 -0
  102. package/src/core/concurrency.ts +73 -0
  103. package/src/core/config.ts +365 -365
  104. package/src/core/constants.ts +95 -95
  105. package/src/core/factory.ts +656 -656
  106. package/src/core/file_checkpoint.ts +163 -136
  107. package/src/core/hooks.ts +126 -126
  108. package/src/core/llm.ts +972 -915
  109. package/src/core/logger.ts +143 -143
  110. package/src/core/mcp.ts +1001 -1001
  111. package/src/core/memory.ts +1201 -1201
  112. package/src/core/middleware.ts +350 -350
  113. package/src/core/model_config.ts +159 -159
  114. package/src/core/pipelines.ts +424 -424
  115. package/src/core/schemas.ts +319 -282
  116. package/src/core/security.ts +27 -0
  117. package/src/core/semantic.ts +211 -211
  118. package/src/core/skill.ts +384 -384
  119. package/src/core/skymd.ts +143 -143
  120. package/src/core/theme.ts +65 -65
  121. package/src/core/tool.ts +457 -457
  122. package/src/core/trace.ts +236 -0
  123. package/src/core/verify.ts +71 -71
  124. package/src/plugins/loader.ts +91 -91
  125. package/src/skills/loader.ts +75 -75
  126. package/src/tools/builtin.ts +571 -493
  127. package/src/tools/computer.ts +279 -279
  128. package/src/tools/extra.ts +662 -0
  129. package/src/tools/guards.ts +82 -0
  130. package/src/tools/model_tool.ts +93 -74
  131. package/src/tools/todo.ts +76 -76
  132. package/src/web/markdown.ts +193 -0
  133. package/src/web/server.ts +117 -693
  134. package/src/web/ui.ts +949 -0
  135. package/tests/agent.test.ts +211 -159
  136. package/tests/agent_helpers.test.ts +48 -48
  137. package/tests/catalog.test.ts +86 -86
  138. package/tests/checkpoint_commands.test.ts +124 -124
  139. package/tests/claude_compat.test.ts +110 -110
  140. package/tests/commands.test.ts +103 -0
  141. package/tests/concurrency.test.ts +102 -0
  142. package/tests/config.test.ts +41 -41
  143. package/tests/extra_tools.test.ts +212 -0
  144. package/tests/fence_plugin.test.ts +52 -52
  145. package/tests/guard.test.ts +75 -75
  146. package/tests/loom.test.ts +337 -337
  147. package/tests/memory.test.ts +170 -170
  148. package/tests/model_config.test.ts +109 -109
  149. package/tests/skymd.test.ts +146 -146
  150. package/tests/ssrf.test.ts +38 -38
  151. package/tests/structured_retry.test.ts +87 -0
  152. package/tests/task.test.ts +60 -60
  153. package/tests/todo_toolstats.test.ts +94 -94
  154. package/tests/trace.test.ts +128 -0
  155. package/tests/tui.test.ts +67 -67
  156. package/tests/web.test.ts +169 -0
  157. package/tsconfig.json +38 -38
@@ -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
+ });
@@ -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
+ });
@@ -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
+ });