skyloom 1.16.2 → 1.18.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 (109) hide show
  1. package/README.md +15 -3
  2. package/dist/cli/loom_chat.d.ts.map +1 -1
  3. package/dist/cli/loom_chat.js +17 -0
  4. package/dist/cli/loom_chat.js.map +1 -1
  5. package/dist/cli/main.js +37 -1
  6. package/dist/cli/main.js.map +1 -1
  7. package/dist/core/agent.d.ts +2 -0
  8. package/dist/core/agent.d.ts.map +1 -1
  9. package/dist/core/agent.js +21 -5
  10. package/dist/core/agent.js.map +1 -1
  11. package/dist/core/bgproc.d.ts +59 -0
  12. package/dist/core/bgproc.d.ts.map +1 -0
  13. package/dist/core/bgproc.js +135 -0
  14. package/dist/core/bgproc.js.map +1 -0
  15. package/dist/core/commands.d.ts.map +1 -1
  16. package/dist/core/commands.js +20 -0
  17. package/dist/core/commands.js.map +1 -1
  18. package/dist/core/diagnostics.d.ts +39 -0
  19. package/dist/core/diagnostics.d.ts.map +1 -0
  20. package/dist/core/diagnostics.js +206 -0
  21. package/dist/core/diagnostics.js.map +1 -0
  22. package/dist/core/diff.d.ts +31 -0
  23. package/dist/core/diff.d.ts.map +1 -0
  24. package/dist/core/diff.js +82 -0
  25. package/dist/core/diff.js.map +1 -0
  26. package/dist/core/envcontext.d.ts +25 -0
  27. package/dist/core/envcontext.d.ts.map +1 -0
  28. package/dist/core/envcontext.js +112 -0
  29. package/dist/core/envcontext.js.map +1 -0
  30. package/dist/core/factory.d.ts +2 -0
  31. package/dist/core/factory.d.ts.map +1 -1
  32. package/dist/core/factory.js +35 -2
  33. package/dist/core/factory.js.map +1 -1
  34. package/dist/core/patch.d.ts +59 -0
  35. package/dist/core/patch.d.ts.map +1 -0
  36. package/dist/core/patch.js +220 -0
  37. package/dist/core/patch.js.map +1 -0
  38. package/dist/core/protocol.d.ts +11 -0
  39. package/dist/core/protocol.d.ts.map +1 -0
  40. package/dist/core/protocol.js +39 -0
  41. package/dist/core/protocol.js.map +1 -0
  42. package/dist/core/sandbox.d.ts +1 -0
  43. package/dist/core/sandbox.d.ts.map +1 -1
  44. package/dist/core/sandbox.js +1 -0
  45. package/dist/core/sandbox.js.map +1 -1
  46. package/dist/core/search.d.ts +41 -0
  47. package/dist/core/search.d.ts.map +1 -0
  48. package/dist/core/search.js +156 -0
  49. package/dist/core/search.js.map +1 -0
  50. package/dist/core/security.d.ts +22 -2
  51. package/dist/core/security.d.ts.map +1 -1
  52. package/dist/core/security.js +55 -24
  53. package/dist/core/security.js.map +1 -1
  54. package/dist/core/skill.d.ts +4 -0
  55. package/dist/core/skill.d.ts.map +1 -1
  56. package/dist/core/skill.js +1 -0
  57. package/dist/core/skill.js.map +1 -1
  58. package/dist/core/subagent.d.ts +75 -0
  59. package/dist/core/subagent.d.ts.map +1 -0
  60. package/dist/core/subagent.js +287 -0
  61. package/dist/core/subagent.js.map +1 -0
  62. package/dist/core/tool.d.ts +23 -1
  63. package/dist/core/tool.d.ts.map +1 -1
  64. package/dist/core/tool.js +95 -30
  65. package/dist/core/tool.js.map +1 -1
  66. package/dist/plugins/loader.d.ts +49 -8
  67. package/dist/plugins/loader.d.ts.map +1 -1
  68. package/dist/plugins/loader.js +129 -16
  69. package/dist/plugins/loader.js.map +1 -1
  70. package/dist/tools/builtin.d.ts.map +1 -1
  71. package/dist/tools/builtin.js +183 -17
  72. package/dist/tools/builtin.js.map +1 -1
  73. package/dist/tools/spawn.d.ts +23 -0
  74. package/dist/tools/spawn.d.ts.map +1 -0
  75. package/dist/tools/spawn.js +77 -0
  76. package/dist/tools/spawn.js.map +1 -0
  77. package/docs/OPTIMIZATION_PLAN.md +21 -4
  78. package/package.json +1 -1
  79. package/src/cli/loom_chat.ts +11 -0
  80. package/src/cli/main.ts +31 -1
  81. package/src/core/agent.ts +20 -5
  82. package/src/core/bgproc.ts +153 -0
  83. package/src/core/commands.ts +20 -0
  84. package/src/core/diagnostics.ts +178 -0
  85. package/src/core/diff.ts +98 -0
  86. package/src/core/envcontext.ts +79 -0
  87. package/src/core/factory.ts +31 -2
  88. package/src/core/patch.ts +176 -0
  89. package/src/core/protocol.ts +36 -0
  90. package/src/core/sandbox.ts +1 -1
  91. package/src/core/search.ts +138 -0
  92. package/src/core/security.ts +63 -21
  93. package/src/core/skill.ts +1 -1
  94. package/src/core/subagent.ts +272 -0
  95. package/src/core/tool.ts +101 -31
  96. package/src/plugins/loader.ts +145 -18
  97. package/src/tools/builtin.ts +167 -17
  98. package/src/tools/spawn.ts +92 -0
  99. package/tests/bgproc.test.ts +65 -0
  100. package/tests/diagnostics.test.ts +86 -0
  101. package/tests/edit_diff.test.ts +102 -0
  102. package/tests/envcontext.test.ts +67 -0
  103. package/tests/patch.test.ts +128 -0
  104. package/tests/plugins.test.ts +84 -0
  105. package/tests/protocol.test.ts +27 -0
  106. package/tests/search.test.ts +87 -0
  107. package/tests/security.test.ts +87 -0
  108. package/tests/subagent.test.ts +211 -0
  109. package/tests/tool.test.ts +120 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * spawn_agent tool — launch a general-purpose, isolated-context subagent to
3
+ * handle one focused, self-contained task and return only its final report.
4
+ *
5
+ * This is sky's analogue of Claude Code's Task tool. Subagent types are
6
+ * discovered from built-ins plus `.claude/agents` / `.sky/agents` definition
7
+ * files (see core/subagent). Subagents never receive spawn_agent themselves,
8
+ * so there is no recursive fan-out.
9
+ */
10
+
11
+ import type { ToolDefinition } from '../core/tool';
12
+ import type { ToolRegistry } from '../core/tool';
13
+ import type { SkillRegistry } from '../core/skill';
14
+ import type { LLMClient } from '../core/llm';
15
+ import type { MessageBus } from '../core/bus';
16
+ import { loadSubagentDefinitions, runSubagent } from '../core/subagent';
17
+
18
+ export function createSpawnAgentTool(opts: {
19
+ config: any;
20
+ llm: LLMClient;
21
+ bus: MessageBus;
22
+ baseToolRegistry: ToolRegistry;
23
+ baseSkillRegistry: SkillRegistry;
24
+ cwd?: string;
25
+ }): ToolDefinition {
26
+ const cwd = opts.cwd || process.cwd();
27
+
28
+ const buildDescription = (): string => {
29
+ const defs = loadSubagentDefinitions(cwd);
30
+ const lines = [...defs.values()].map((d) => ` - ${d.name}: ${d.description}`);
31
+ return (
32
+ '派生一个隔离上下文的子智能体来独立完成一个聚焦、自洽的任务,只返回它的最终报告。' +
33
+ '当任务需要大量搜索/调研、或你想把一段独立工作从主上下文里隔离出去时使用。' +
34
+ '子智能体看不到你的对话历史,所以 task 必须自带全部所需上下文(目标、相关文件、约束)。' +
35
+ '它无法反问你,会一次性完成并汇报。可并行派生多个互不依赖的子智能体。\n\n可用子智能体类型:\n' +
36
+ lines.join('\n')
37
+ );
38
+ };
39
+
40
+ return {
41
+ name: 'spawn_agent',
42
+ description: buildDescription(),
43
+ parameters: [
44
+ {
45
+ name: 'agent_type',
46
+ type: 'string',
47
+ description: '子智能体类型(见工具描述中的可用类型,如 general-purpose / explore 或自定义)',
48
+ required: true,
49
+ },
50
+ {
51
+ name: 'task',
52
+ type: 'string',
53
+ description: '完整、自洽的任务描述。子智能体看不到对话历史,必须在此写清目标、相关文件路径与约束。',
54
+ required: true,
55
+ },
56
+ {
57
+ name: 'description',
58
+ type: 'string',
59
+ description: '可选:对该任务的简短(3-5 词)标签,用于展示。',
60
+ required: false,
61
+ },
62
+ ],
63
+ // Long-running by nature (full nested agent loop); give it generous headroom.
64
+ timeout: 600000,
65
+ handler: async (params) => {
66
+ const agentType = String(params.agent_type || '').trim();
67
+ const task = String(params.task || '').trim();
68
+ if (!agentType) return '[spawn_agent error] agent_type is required.';
69
+ if (!task) return '[spawn_agent error] task is required.';
70
+
71
+ const defs = loadSubagentDefinitions(cwd);
72
+ const def = defs.get(agentType);
73
+ if (!def) {
74
+ const available = [...defs.keys()].join(', ');
75
+ return `[spawn_agent error] unknown agent_type '${agentType}'. Available: ${available}`;
76
+ }
77
+
78
+ const report = await runSubagent({
79
+ def,
80
+ task,
81
+ config: opts.config,
82
+ llm: opts.llm,
83
+ bus: opts.bus,
84
+ baseToolRegistry: opts.baseToolRegistry,
85
+ baseSkillRegistry: opts.baseSkillRegistry,
86
+ });
87
+
88
+ const header = `[subagent ${def.name} 完成]`;
89
+ return `${header}\n${report}`;
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getBackgroundManager } from "../src/core/bgproc";
3
+
4
+ const NODE = `"${process.execPath}"`;
5
+
6
+ async function waitFor(pred: () => boolean, timeoutMs = 5000): Promise<boolean> {
7
+ const t0 = Date.now();
8
+ while (Date.now() - t0 < timeoutMs) {
9
+ if (pred()) return true;
10
+ await new Promise((r) => setTimeout(r, 25));
11
+ }
12
+ return pred();
13
+ }
14
+
15
+ describe("bgproc · background process manager", () => {
16
+ it("runs a command to completion and captures its output", async () => {
17
+ const mgr = getBackgroundManager();
18
+ const { id, error } = mgr.start(`${NODE} -e "process.stdout.write('BGHELLO')"`);
19
+ expect(error).toBeUndefined();
20
+ expect(id).toBeTruthy();
21
+
22
+ const done = await waitFor(() => mgr.get(id!)?.status !== "running");
23
+ expect(done).toBe(true);
24
+ await new Promise((r) => setTimeout(r, 50)); // let final stdout flush
25
+
26
+ const r = mgr.read(id!);
27
+ expect(r.ok).toBe(true);
28
+ expect(r.text).toContain("BGHELLO");
29
+ expect(mgr.get(id!)?.status).toBe("exited");
30
+ });
31
+
32
+ it("incremental read advances the cursor (second read is empty)", async () => {
33
+ const mgr = getBackgroundManager();
34
+ const { id } = mgr.start(`${NODE} -e "process.stdout.write('ONCE')"`);
35
+ await waitFor(() => mgr.get(id!)?.status !== "running");
36
+ await new Promise((r) => setTimeout(r, 50));
37
+ expect(mgr.read(id!).text).toContain("ONCE");
38
+ expect(mgr.read(id!).text).toBe(""); // already consumed
39
+ });
40
+
41
+ it("lists jobs and kills a long-running one", async () => {
42
+ const mgr = getBackgroundManager();
43
+ const { id } = mgr.start(`${NODE} -e "setInterval(()=>{},1000)"`);
44
+ expect(mgr.list().some((j) => j.id === id)).toBe(true);
45
+ expect(mgr.get(id!)?.status).toBe("running");
46
+
47
+ const k = mgr.kill(id!);
48
+ expect(k.ok).toBe(true);
49
+ const killed = await waitFor(() => mgr.get(id!)?.status === "killed");
50
+ expect(killed).toBe(true);
51
+ });
52
+
53
+ it("blocks red-line commands before spawning", () => {
54
+ const mgr = getBackgroundManager();
55
+ const { id, error } = mgr.start("rm -rf /");
56
+ expect(id).toBeUndefined();
57
+ expect(error).toMatch(/BLOCKED|REDLINE/);
58
+ });
59
+
60
+ it("errors on read/kill of an unknown job", () => {
61
+ const mgr = getBackgroundManager();
62
+ expect(mgr.read("nope").ok).toBe(false);
63
+ expect(mgr.kill("nope").ok).toBe(false);
64
+ });
65
+ });
@@ -0,0 +1,86 @@
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 {
6
+ getDiagnostics,
7
+ getTypeScriptDiagnostics,
8
+ parseDiagnosticOutput,
9
+ formatDiagnostics,
10
+ } from "../src/core/diagnostics";
11
+
12
+ describe("diagnostics · TypeScript compiler API", () => {
13
+ let dir: string;
14
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-diag-")); });
15
+ afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
16
+
17
+ function write(name: string, content: string): string {
18
+ const p = path.join(dir, name);
19
+ fs.writeFileSync(p, content);
20
+ return p;
21
+ }
22
+
23
+ it("reports a type error with line:col and TS code", () => {
24
+ const p = write("bad.ts", "const x: number = 'hello';\n");
25
+ const res = getTypeScriptDiagnostics(p);
26
+ expect(Array.isArray(res)).toBe(true);
27
+ const diags = res as any[];
28
+ expect(diags.length).toBeGreaterThan(0);
29
+ const err = diags[0];
30
+ expect(err.severity).toBe("error");
31
+ expect(err.line).toBe(1);
32
+ expect(err.code).toMatch(/^TS\d+/);
33
+ expect(err.source).toBe("ts");
34
+ });
35
+
36
+ it("returns an empty array for a clean file", () => {
37
+ const p = write("ok.ts", "export const y: number = 5;\n");
38
+ const res = getTypeScriptDiagnostics(p);
39
+ expect(Array.isArray(res)).toBe(true);
40
+ expect((res as any[]).length).toBe(0);
41
+ });
42
+
43
+ it("getDiagnostics dispatches TS files to the compiler API", () => {
44
+ const p = write("d.ts", "let n: string = 42;\n");
45
+ const res = getDiagnostics(p, {});
46
+ expect(Array.isArray(res)).toBe(true);
47
+ expect((res as any[]).length).toBeGreaterThan(0);
48
+ });
49
+
50
+ it("returns unavailable for an unconfigured non-TS extension", () => {
51
+ const p = write("script.rb", "puts 'hi'\n");
52
+ const res = getDiagnostics(p, {});
53
+ expect(Array.isArray(res)).toBe(false);
54
+ expect((res as any).unavailable).toContain("no diagnostics provider");
55
+ });
56
+
57
+ it("returns unavailable for a missing file", () => {
58
+ const res = getDiagnostics(path.join(dir, "nope.ts"), {});
59
+ expect((res as any).unavailable).toContain("not found");
60
+ });
61
+ });
62
+
63
+ describe("diagnostics · external output parsing", () => {
64
+ it("parses path:line:col: severity message lines", () => {
65
+ const out = "src/a.py:3:5: error: undefined name 'x'\nsrc/a.py:7:1: warning: unused import";
66
+ const diags = parseDiagnosticOutput(out, "ruff");
67
+ expect(diags.length).toBe(2);
68
+ expect(diags[0]).toMatchObject({ line: 3, column: 5, severity: "error" });
69
+ expect(diags[1].severity).toBe("warning");
70
+ expect(diags[0].source).toBe("ruff");
71
+ });
72
+ });
73
+
74
+ describe("diagnostics · formatting", () => {
75
+ it("formats a clean result", () => {
76
+ expect(formatDiagnostics("a.ts", [])).toContain("no diagnostics");
77
+ });
78
+ it("formats errors with counts", () => {
79
+ const s = formatDiagnostics("a.ts", [
80
+ { line: 2, column: 3, severity: "error", message: "boom", code: "TS1", source: "ts" },
81
+ ]);
82
+ expect(s).toContain("1 error");
83
+ expect(s).toContain("2:3");
84
+ expect(s).toContain("boom");
85
+ });
86
+ });
@@ -0,0 +1,102 @@
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 { unifiedDiff, countOccurrences } from "../src/core/diff";
6
+ import { ToolRegistry } from "../src/core/tool";
7
+ import { registerBuiltinTools } from "../src/tools/builtin";
8
+
9
+ describe("diff · unifiedDiff", () => {
10
+ it("returns empty for identical input", () => {
11
+ const d = unifiedDiff("a\nb\nc", "a\nb\nc");
12
+ expect(d.text).toBe("");
13
+ expect(d.stat).toEqual({ added: 0, removed: 0 });
14
+ });
15
+
16
+ it("shows a single changed line with context and a +/- stat", () => {
17
+ const d = unifiedDiff("a\nb\nc\nd\ne", "a\nb\nX\nd\ne");
18
+ expect(d.stat).toEqual({ added: 1, removed: 1 });
19
+ expect(d.text).toContain("-c");
20
+ expect(d.text).toContain("+X");
21
+ expect(d.text).toContain(" b"); // context line
22
+ expect(d.text).toMatch(/@@ -\d+,\d+ \+\d+,\d+ @@/);
23
+ });
24
+
25
+ it("counts added and removed lines for multi-line changes", () => {
26
+ const d = unifiedDiff("x\n1\n2\ny", "x\n1\n2\n3\ny");
27
+ expect(d.stat.added).toBe(1);
28
+ expect(d.stat.removed).toBe(0);
29
+ });
30
+ });
31
+
32
+ describe("diff · countOccurrences", () => {
33
+ it("counts non-overlapping occurrences", () => {
34
+ expect(countOccurrences("aaaa", "aa")).toBe(2);
35
+ expect(countOccurrences("abcabc", "abc")).toBe(2);
36
+ expect(countOccurrences("abc", "z")).toBe(0);
37
+ expect(countOccurrences("abc", "")).toBe(0);
38
+ });
39
+ });
40
+
41
+ describe("edit_file · Claude Code-style semantics", () => {
42
+ let dir: string;
43
+ let edit: any;
44
+ beforeEach(() => {
45
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-edit-"));
46
+ const reg = new ToolRegistry();
47
+ registerBuiltinTools(reg);
48
+ edit = reg.get("edit_file")!.handler!;
49
+ });
50
+ afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
51
+
52
+ function write(name: string, content: string): string {
53
+ const p = path.join(dir, name);
54
+ fs.writeFileSync(p, content);
55
+ return p;
56
+ }
57
+
58
+ it("replaces a unique match and returns a diff", async () => {
59
+ const p = write("a.txt", "line1\nfoo\nline3\n");
60
+ const out = await edit({ path: p, old_text: "foo", new_text: "bar" });
61
+ expect(out).toContain("Successfully edited");
62
+ expect(out).toContain("-foo");
63
+ expect(out).toContain("+bar");
64
+ expect(fs.readFileSync(p, "utf8")).toBe("line1\nbar\nline3\n");
65
+ });
66
+
67
+ it("refuses an ambiguous edit when old_text is not unique", async () => {
68
+ const p = write("b.txt", "x\nx\n");
69
+ const out = await edit({ path: p, old_text: "x", new_text: "y" });
70
+ expect(out).toContain("appears 2 times");
71
+ expect(out).toContain("replace_all");
72
+ // file unchanged
73
+ expect(fs.readFileSync(p, "utf8")).toBe("x\nx\n");
74
+ });
75
+
76
+ it("replace_all changes every occurrence", async () => {
77
+ const p = write("c.txt", "x\nx\nx\n");
78
+ const out = await edit({ path: p, old_text: "x", new_text: "y", replace_all: true });
79
+ expect(out).toContain("3 occurrences");
80
+ expect(fs.readFileSync(p, "utf8")).toBe("y\ny\ny\n");
81
+ });
82
+
83
+ it("errors when old_text is missing from the file", async () => {
84
+ const p = write("d.txt", "hello\n");
85
+ const out = await edit({ path: p, old_text: "nope", new_text: "x" });
86
+ expect(out).toContain("not found");
87
+ expect(fs.readFileSync(p, "utf8")).toBe("hello\n");
88
+ });
89
+
90
+ it("rejects a no-op edit (old_text === new_text)", async () => {
91
+ const p = write("e.txt", "same\n");
92
+ const out = await edit({ path: p, old_text: "same", new_text: "same" });
93
+ expect(out).toContain("identical");
94
+ });
95
+
96
+ it("treats $-patterns in new_text literally (no String.replace interpretation)", async () => {
97
+ const p = write("f.txt", "value = OLD\n");
98
+ const out = await edit({ path: p, old_text: "OLD", new_text: "$1$&dollar" });
99
+ expect(out).toContain("Successfully edited");
100
+ expect(fs.readFileSync(p, "utf8")).toBe("value = $1$&dollar\n");
101
+ });
102
+ });
@@ -0,0 +1,67 @@
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 { gitInfo, buildEnvBlock } from "../src/core/envcontext";
6
+
7
+ describe("envcontext · gitInfo", () => {
8
+ let dir: string;
9
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-git-")); });
10
+ afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
11
+
12
+ it("reads the branch from a .git directory HEAD", () => {
13
+ fs.mkdirSync(path.join(dir, ".git"));
14
+ fs.writeFileSync(path.join(dir, ".git", "HEAD"), "ref: refs/heads/feature-x\n");
15
+ const info = gitInfo(dir);
16
+ expect(info.repo).toBe(true);
17
+ expect(info.branch).toBe("feature-x");
18
+ });
19
+
20
+ it("falls back to a short sha for a detached HEAD", () => {
21
+ fs.mkdirSync(path.join(dir, ".git"));
22
+ fs.writeFileSync(path.join(dir, ".git", "HEAD"), "0123456789abcdef\n");
23
+ expect(gitInfo(dir).branch).toBe("01234567");
24
+ });
25
+
26
+ it("resolves a worktree .git file pointing at the real gitdir", () => {
27
+ const real = path.join(dir, "realgit");
28
+ fs.mkdirSync(real);
29
+ fs.writeFileSync(path.join(real, "HEAD"), "ref: refs/heads/wt-branch\n");
30
+ fs.writeFileSync(path.join(dir, ".git"), `gitdir: ${real}\n`);
31
+ const info = gitInfo(dir);
32
+ expect(info.repo).toBe(true);
33
+ expect(info.branch).toBe("wt-branch");
34
+ });
35
+
36
+ it("reports no repo when there is no .git up the tree", () => {
37
+ const nested = path.join(dir, "a", "b");
38
+ fs.mkdirSync(nested, { recursive: true });
39
+ expect(gitInfo(nested).repo).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("envcontext · buildEnvBlock", () => {
44
+ it("includes cwd, platform, node, git branch and an injectable date (zh)", () => {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-env-"));
46
+ try {
47
+ fs.mkdirSync(path.join(dir, ".git"));
48
+ fs.writeFileSync(path.join(dir, ".git", "HEAD"), "ref: refs/heads/main\n");
49
+ const block = buildEnvBlock({ cwd: dir, lang: "zh", now: new Date("2026-06-14T08:00:00Z") });
50
+ expect(block).toContain("运行环境");
51
+ expect(block).toContain(dir);
52
+ expect(block).toContain(process.version);
53
+ expect(block).toContain(process.platform);
54
+ expect(block).toContain("main");
55
+ expect(block).toContain("2026-06-14");
56
+ } finally {
57
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
58
+ }
59
+ });
60
+
61
+ it("renders an English block", () => {
62
+ const block = buildEnvBlock({ cwd: os.tmpdir(), lang: "en", now: new Date("2026-01-02T00:00:00Z") });
63
+ expect(block).toContain("## Environment");
64
+ expect(block).toContain("Working directory");
65
+ expect(block).toContain("2026-01-02");
66
+ });
67
+ });
@@ -0,0 +1,128 @@
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 { parsePatch, applyPatch } from "../src/core/patch";
6
+
7
+ describe("patch · parsePatch", () => {
8
+ it("parses update / add / delete operations", () => {
9
+ const text = [
10
+ "*** Begin Patch",
11
+ "*** Update File: a.ts",
12
+ "<<<<<<< SEARCH",
13
+ "const x = 1;",
14
+ "=======",
15
+ "const x = 2;",
16
+ ">>>>>>> REPLACE",
17
+ "*** Add File: b.ts",
18
+ "export const y = 3;",
19
+ "*** Delete File: c.ts",
20
+ "*** End Patch",
21
+ ].join("\n");
22
+ const r = parsePatch(text) as any;
23
+ expect(r.error).toBeUndefined();
24
+ expect(r.ops).toHaveLength(3);
25
+ expect(r.ops[0]).toMatchObject({ op: "update", path: "a.ts" });
26
+ expect(r.ops[0].blocks[0]).toEqual({ search: "const x = 1;", replace: "const x = 2;" });
27
+ expect(r.ops[1]).toMatchObject({ op: "add", path: "b.ts", content: "export const y = 3;\n" });
28
+ expect(r.ops[2]).toMatchObject({ op: "delete", path: "c.ts" });
29
+ });
30
+
31
+ it("errors on an unterminated SEARCH", () => {
32
+ const text = "*** Update File: a.ts\n<<<<<<< SEARCH\nfoo\n";
33
+ expect((parsePatch(text) as any).error).toContain("Unterminated SEARCH");
34
+ });
35
+
36
+ it("errors on stray content outside a file section", () => {
37
+ expect((parsePatch("hello world") as any).error).toContain("Unexpected line");
38
+ });
39
+ });
40
+
41
+ describe("patch · applyPatch (atomic, multi-file)", () => {
42
+ let dir: string;
43
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-patch-")); });
44
+ afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
45
+
46
+ function write(name: string, content: string) {
47
+ const p = path.join(dir, name);
48
+ fs.mkdirSync(path.dirname(p), { recursive: true });
49
+ fs.writeFileSync(p, content);
50
+ }
51
+ const read = (name: string) => fs.readFileSync(path.join(dir, name), "utf8");
52
+
53
+ it("applies update + add + delete in one shot", () => {
54
+ write("a.ts", "const x = 1;\nkeep me\n");
55
+ write("c.ts", "delete me\n");
56
+ const patch = [
57
+ "*** Update File: a.ts",
58
+ "<<<<<<< SEARCH",
59
+ "const x = 1;",
60
+ "=======",
61
+ "const x = 42;",
62
+ ">>>>>>> REPLACE",
63
+ "*** Add File: sub/b.ts",
64
+ "export const y = 3;",
65
+ "*** Delete File: c.ts",
66
+ ].join("\n");
67
+ const out = applyPatch(patch, { cwd: dir });
68
+ expect(out).toContain("Applied patch");
69
+ expect(read("a.ts")).toBe("const x = 42;\nkeep me\n");
70
+ expect(read("sub/b.ts")).toBe("export const y = 3;\n");
71
+ expect(fs.existsSync(path.join(dir, "c.ts"))).toBe(false);
72
+ });
73
+
74
+ it("applies multiple blocks to one file", () => {
75
+ write("m.ts", "alpha\nbeta\ngamma\n");
76
+ const patch = [
77
+ "*** Update File: m.ts",
78
+ "<<<<<<< SEARCH", "alpha", "=======", "ALPHA", ">>>>>>> REPLACE",
79
+ "<<<<<<< SEARCH", "gamma", "=======", "GAMMA", ">>>>>>> REPLACE",
80
+ ].join("\n");
81
+ applyPatch(patch, { cwd: dir });
82
+ expect(read("m.ts")).toBe("ALPHA\nbeta\nGAMMA\n");
83
+ });
84
+
85
+ it("is atomic: a failing block leaves ALL files untouched", () => {
86
+ write("a.ts", "good\n");
87
+ write("b.ts", "target\n");
88
+ const patch = [
89
+ "*** Update File: a.ts",
90
+ "<<<<<<< SEARCH", "good", "=======", "changed", ">>>>>>> REPLACE",
91
+ "*** Update File: b.ts",
92
+ "<<<<<<< SEARCH", "NOT THERE", "=======", "x", ">>>>>>> REPLACE",
93
+ ].join("\n");
94
+ const out = applyPatch(patch, { cwd: dir });
95
+ expect(out).toContain("SEARCH block not found");
96
+ expect(read("a.ts")).toBe("good\n"); // first file NOT written
97
+ expect(read("b.ts")).toBe("target\n");
98
+ });
99
+
100
+ it("rejects an ambiguous SEARCH block", () => {
101
+ write("d.ts", "dup\ndup\n");
102
+ const patch = ["*** Update File: d.ts", "<<<<<<< SEARCH", "dup", "=======", "x", ">>>>>>> REPLACE"].join("\n");
103
+ expect(applyPatch(patch, { cwd: dir })).toContain("ambiguous");
104
+ });
105
+
106
+ it("refuses to Add over an existing file", () => {
107
+ write("exists.ts", "already\n");
108
+ const patch = "*** Add File: exists.ts\nnew content\n";
109
+ const out = applyPatch(patch, { cwd: dir });
110
+ expect(out).toContain("already exists");
111
+ expect(read("exists.ts")).toBe("already\n");
112
+ });
113
+
114
+ it("treats $-patterns in replacement literally", () => {
115
+ write("e.ts", "VAL\n");
116
+ const patch = ["*** Update File: e.ts", "<<<<<<< SEARCH", "VAL", "=======", "$1$&x", ">>>>>>> REPLACE"].join("\n");
117
+ applyPatch(patch, { cwd: dir });
118
+ expect(read("e.ts")).toBe("$1$&x\n");
119
+ });
120
+
121
+ it("honors the fence check by aborting", () => {
122
+ write("a.ts", "good\n");
123
+ const patch = ["*** Update File: a.ts", "<<<<<<< SEARCH", "good", "=======", "bad", ">>>>>>> REPLACE"].join("\n");
124
+ const out = applyPatch(patch, { cwd: dir, fenceCheck: () => "路径越界" });
125
+ expect(out).toContain("路径越界");
126
+ expect(read("a.ts")).toBe("good\n");
127
+ });
128
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { ToolRegistry } from "../src/core/tool";
3
+ import { PluginLoader, type PluginContext } from "../src/plugins/loader";
4
+
5
+ describe("PluginLoader · hook lifecycle", () => {
6
+ let reg: ToolRegistry;
7
+ let loader: PluginLoader;
8
+ beforeEach(() => {
9
+ reg = new ToolRegistry();
10
+ loader = new PluginLoader(reg, { foo: 1 });
11
+ });
12
+
13
+ it("activate-style plugin registers a scoped tool and an init hook", async () => {
14
+ let initFired = false;
15
+ loader.activatePlugin("p1", {
16
+ activate(ctx: PluginContext) {
17
+ expect(ctx.config).toEqual({ foo: 1 });
18
+ ctx.registerTool({ name: "p1_tool", description: "t", handler: async () => "ok" });
19
+ ctx.on("init", () => { initFired = true; });
20
+ },
21
+ });
22
+ expect(reg.has("p1_tool")).toBe(true);
23
+ expect(loader.list()).toContain("p1");
24
+ expect(loader.hookCount("init")).toBe(1);
25
+
26
+ await loader.emit("init");
27
+ expect(initFired).toBe(true);
28
+ });
29
+
30
+ it("unload removes the plugin's tools and hook handlers", async () => {
31
+ let fired = 0;
32
+ loader.activatePlugin("p", {
33
+ activate(ctx: PluginContext) {
34
+ ctx.registerTool({ name: "tmp_tool", description: "t", handler: async () => "ok" });
35
+ ctx.on("init", () => { fired++; });
36
+ },
37
+ });
38
+ expect(reg.has("tmp_tool")).toBe(true);
39
+
40
+ expect(loader.unload("p")).toBe(true);
41
+ expect(reg.has("tmp_tool")).toBe(false);
42
+ expect(loader.hookCount("init")).toBe(0);
43
+ expect(loader.list()).not.toContain("p");
44
+
45
+ await loader.emit("init");
46
+ expect(fired).toBe(0); // handler gone
47
+ });
48
+
49
+ it("fires hook handlers in registration order across plugins", async () => {
50
+ const order: string[] = [];
51
+ loader.activatePlugin("a", { activate: (c) => c.on("init", () => { order.push("a"); }) });
52
+ loader.activatePlugin("b", { activate: (c) => c.on("init", () => { order.push("b"); }) });
53
+ await loader.emit("init");
54
+ expect(order).toEqual(["a", "b"]);
55
+ });
56
+
57
+ it("reactivating a name unloads the previous instance (no duplicate tools)", () => {
58
+ loader.activatePlugin("dup", { activate: (c) => c.registerTool({ name: "x", description: "v1", handler: async () => "1" }) });
59
+ loader.activatePlugin("dup", { activate: (c) => c.registerTool({ name: "x", description: "v2", handler: async () => "2" }) });
60
+ expect(loader.list().filter((n) => n === "dup")).toHaveLength(1);
61
+ expect(reg.get("x")?.description).toBe("v2");
62
+ });
63
+
64
+ it("supports legacy register(registry) and tracks its tools for unload", () => {
65
+ loader.activatePlugin("legacy", {
66
+ register(r) { r.register({ name: "legacy_tool", description: "t", handler: async () => "ok" }); },
67
+ });
68
+ expect(reg.has("legacy_tool")).toBe(true);
69
+ loader.unload("legacy");
70
+ expect(reg.has("legacy_tool")).toBe(false);
71
+ });
72
+
73
+ it("isolates a throwing hook handler from the rest", async () => {
74
+ const ran: string[] = [];
75
+ loader.activatePlugin("a", { activate: (c) => c.on("evt", () => { throw new Error("boom"); }) });
76
+ loader.activatePlugin("b", { activate: (c) => c.on("evt", () => { ran.push("b"); }) });
77
+ await loader.emit("evt");
78
+ expect(ran).toEqual(["b"]); // b still ran despite a throwing
79
+ });
80
+
81
+ it("unload returns false for an unknown plugin", () => {
82
+ expect(loader.unload("nope")).toBe(false);
83
+ });
84
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { engineeringProtocol } from "../src/core/protocol";
3
+
4
+ describe("protocol · engineeringProtocol", () => {
5
+ it("zh protocol covers the senior-engineer discipline and names real tools", () => {
6
+ const p = engineeringProtocol("zh");
7
+ expect(p).toContain("工程标准");
8
+ expect(p).toContain("根因"); // root-cause, not symptom
9
+ expect(p).toContain("最小"); // minimal diffs
10
+ expect(p).toContain("先理解"); // understand before changing
11
+ expect(p).toContain("get_diagnostics"); // verify loop wired to real tool
12
+ expect(p).toContain("code_search");
13
+ });
14
+
15
+ it("en protocol mirrors the zh one", () => {
16
+ const p = engineeringProtocol("en");
17
+ expect(p).toContain("Engineering Standard");
18
+ expect(p).toContain("Root cause");
19
+ expect(p).toContain("Minimal");
20
+ expect(p).toContain("get_diagnostics");
21
+ expect(p).toContain("code_search");
22
+ });
23
+
24
+ it("defaults to zh", () => {
25
+ expect(engineeringProtocol()).toBe(engineeringProtocol("zh"));
26
+ });
27
+ });