oh-pi 0.1.70 → 0.1.71
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/dist/i18n.test.d.ts +1 -0
- package/dist/i18n.test.js +56 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +68 -0
- package/dist/tui/confirm-apply.d.ts +7 -0
- package/dist/tui/confirm-apply.js +1 -1
- package/dist/tui/confirm-apply.test.d.ts +1 -0
- package/dist/tui/confirm-apply.test.js +20 -0
- package/dist/tui/provider-setup.d.ts +2 -0
- package/dist/tui/provider-setup.js +1 -1
- package/dist/tui/provider-setup.test.d.ts +1 -0
- package/dist/tui/provider-setup.test.js +40 -0
- package/dist/tui/welcome.d.ts +6 -0
- package/dist/tui/welcome.js +1 -1
- package/dist/tui/welcome.test.d.ts +1 -0
- package/dist/tui/welcome.test.js +25 -0
- package/dist/utils/resources.test.d.ts +1 -0
- package/dist/utils/resources.test.js +39 -0
- package/package.json +5 -3
- package/pi-package/extensions/ant-colony/concurrency.test.ts +70 -0
- package/pi-package/extensions/ant-colony/deps.test.ts +62 -0
- package/pi-package/extensions/ant-colony/index.ts +4 -0
- package/pi-package/extensions/ant-colony/nest.ts +25 -44
- package/pi-package/extensions/ant-colony/parser.test.ts +110 -0
- package/pi-package/extensions/ant-colony/prompts.test.ts +57 -0
- package/pi-package/extensions/ant-colony/queen.ts +12 -11
- package/pi-package/extensions/ant-colony/spawner.test.ts +44 -0
- package/pi-package/extensions/ant-colony/spawner.ts +2 -0
- package/pi-package/extensions/ant-colony/types.test.ts +36 -0
- package/pi-package/extensions/ant-colony/ui.test.ts +66 -0
- package/pi-package/extensions/auto-update.test.ts +15 -0
- package/pi-package/extensions/auto-update.ts +1 -1
- package/pi-package/extensions/safe-guard.test.ts +26 -0
- package/pi-package/extensions/safe-guard.ts +2 -2
- package/pi-package/extensions/smart-compact.test.ts +64 -0
- package/pi-package/extensions/smart-compact.ts +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { t, setLocale, getLocale } from "./i18n.js";
|
|
3
|
+
afterEach(() => setLocale("en"));
|
|
4
|
+
describe("t", () => {
|
|
5
|
+
it("returns known en key", () => {
|
|
6
|
+
expect(t("welcome.title")).toContain("oh-pi");
|
|
7
|
+
});
|
|
8
|
+
it("interpolates vars", () => {
|
|
9
|
+
const result = t("welcome.piDetected", { version: "1.0" });
|
|
10
|
+
expect(result).toContain("1.0");
|
|
11
|
+
});
|
|
12
|
+
it("falls back to en when zh key missing", () => {
|
|
13
|
+
setLocale("zh");
|
|
14
|
+
const enVal = t("welcome.title");
|
|
15
|
+
expect(enVal).toBeTruthy();
|
|
16
|
+
expect(enVal).not.toBe("welcome.title");
|
|
17
|
+
});
|
|
18
|
+
it("falls back to key string when key missing in all locales", () => {
|
|
19
|
+
expect(t("nonexistent.key.xyz")).toBe("nonexistent.key.xyz");
|
|
20
|
+
});
|
|
21
|
+
it("setLocale/getLocale round-trip en", () => {
|
|
22
|
+
setLocale("en");
|
|
23
|
+
expect(getLocale()).toBe("en");
|
|
24
|
+
});
|
|
25
|
+
it("setLocale/getLocale round-trip zh", () => {
|
|
26
|
+
setLocale("zh");
|
|
27
|
+
expect(getLocale()).toBe("zh");
|
|
28
|
+
});
|
|
29
|
+
it("setLocale/getLocale round-trip fr", () => {
|
|
30
|
+
setLocale("fr");
|
|
31
|
+
expect(getLocale()).toBe("fr");
|
|
32
|
+
});
|
|
33
|
+
it("returns zh translation after setLocale zh", () => {
|
|
34
|
+
const enResult = t("welcome.title");
|
|
35
|
+
setLocale("zh");
|
|
36
|
+
const zhResult = t("welcome.title");
|
|
37
|
+
expect(zhResult).toContain("oh-pi");
|
|
38
|
+
expect(zhResult).not.toBe(enResult);
|
|
39
|
+
});
|
|
40
|
+
it("interpolates multiple vars", () => {
|
|
41
|
+
const result = t("welcome.envInfo", { terminal: "xterm", os: "linux", node: "v20" });
|
|
42
|
+
expect(result).toContain("xterm");
|
|
43
|
+
expect(result).toContain("linux");
|
|
44
|
+
});
|
|
45
|
+
it("returns string without vars unchanged", () => {
|
|
46
|
+
expect(typeof t("cancelled")).toBe("string");
|
|
47
|
+
});
|
|
48
|
+
it("does not crash with empty vars", () => {
|
|
49
|
+
expect(t("welcome.title", {})).toContain("oh-pi");
|
|
50
|
+
});
|
|
51
|
+
it("fr locale returns fr translation", () => {
|
|
52
|
+
setLocale("fr");
|
|
53
|
+
const result = t("welcome.title");
|
|
54
|
+
expect(result).toContain("oh-pi");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { MODEL_CAPABILITIES, PROVIDERS, THEMES, EXTENSIONS, KEYBINDING_SCHEMES } from "./registry.js";
|
|
3
|
+
describe("MODEL_CAPABILITIES", () => {
|
|
4
|
+
it("has entries", () => {
|
|
5
|
+
expect(Object.keys(MODEL_CAPABILITIES).length).toBeGreaterThan(0);
|
|
6
|
+
});
|
|
7
|
+
it("each entry has required fields", () => {
|
|
8
|
+
for (const [, cap] of Object.entries(MODEL_CAPABILITIES)) {
|
|
9
|
+
expect(cap).toHaveProperty("contextWindow");
|
|
10
|
+
expect(cap).toHaveProperty("maxTokens");
|
|
11
|
+
expect(cap).toHaveProperty("reasoning");
|
|
12
|
+
expect(cap).toHaveProperty("input");
|
|
13
|
+
expect(cap.contextWindow).toBeGreaterThan(0);
|
|
14
|
+
expect(cap.maxTokens).toBeGreaterThan(0);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe("PROVIDERS", () => {
|
|
19
|
+
const expected = ["anthropic", "openai", "google", "groq", "openrouter", "xai", "mistral"];
|
|
20
|
+
it("has 7 providers", () => {
|
|
21
|
+
expect(Object.keys(PROVIDERS)).toHaveLength(7);
|
|
22
|
+
});
|
|
23
|
+
it("contains all expected providers", () => {
|
|
24
|
+
for (const name of expected) {
|
|
25
|
+
expect(PROVIDERS).toHaveProperty(name);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it("each provider has env/label/models", () => {
|
|
29
|
+
for (const [, info] of Object.entries(PROVIDERS)) {
|
|
30
|
+
expect(info.env).toMatch(/_API_KEY$|_KEY$/);
|
|
31
|
+
expect(info.label).toBeTruthy();
|
|
32
|
+
expect(Array.isArray(info.models)).toBe(true);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("THEMES", () => {
|
|
37
|
+
it("is array with entries", () => {
|
|
38
|
+
expect(Array.isArray(THEMES)).toBe(true);
|
|
39
|
+
expect(THEMES.length).toBeGreaterThan(0);
|
|
40
|
+
});
|
|
41
|
+
it("each has name/label/style", () => {
|
|
42
|
+
for (const theme of THEMES) {
|
|
43
|
+
expect(theme).toHaveProperty("name");
|
|
44
|
+
expect(theme).toHaveProperty("label");
|
|
45
|
+
expect(theme).toHaveProperty("style");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe("EXTENSIONS", () => {
|
|
50
|
+
it("is array with entries", () => {
|
|
51
|
+
expect(Array.isArray(EXTENSIONS)).toBe(true);
|
|
52
|
+
expect(EXTENSIONS.length).toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
it("each has name/label/default", () => {
|
|
55
|
+
for (const ext of EXTENSIONS) {
|
|
56
|
+
expect(ext).toHaveProperty("name");
|
|
57
|
+
expect(ext).toHaveProperty("label");
|
|
58
|
+
expect(ext).toHaveProperty("default");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("KEYBINDING_SCHEMES", () => {
|
|
63
|
+
it("has default/vim/emacs", () => {
|
|
64
|
+
expect(KEYBINDING_SCHEMES).toHaveProperty("default");
|
|
65
|
+
expect(KEYBINDING_SCHEMES).toHaveProperty("vim");
|
|
66
|
+
expect(KEYBINDING_SCHEMES).toHaveProperty("emacs");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { OhPConfig } from "../types.js";
|
|
2
2
|
import type { EnvInfo } from "../utils/detect.js";
|
|
3
|
+
/**
|
|
4
|
+
* 统计已有配置中指定目录下的文件数量。
|
|
5
|
+
* @param env - 环境信息
|
|
6
|
+
* @param dir - 目录名称前缀
|
|
7
|
+
* @returns 匹配的文件数
|
|
8
|
+
*/
|
|
9
|
+
export declare function countExisting(env: EnvInfo, dir: string): number;
|
|
3
10
|
/**
|
|
4
11
|
* 展示配置摘要,处理已有配置的备份/覆盖,安装 pi(如需),并应用最终配置。
|
|
5
12
|
* @param config - 用户选择的配置对象
|
|
@@ -8,7 +8,7 @@ import { applyConfig, installPi, backupConfig } from "../utils/install.js";
|
|
|
8
8
|
* @param dir - 目录名称前缀
|
|
9
9
|
* @returns 匹配的文件数
|
|
10
10
|
*/
|
|
11
|
-
function countExisting(env, dir) {
|
|
11
|
+
export function countExisting(env, dir) {
|
|
12
12
|
return env.existingFiles.filter(f => f.startsWith(dir + "/")).length;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { countExisting } from "./confirm-apply.js";
|
|
3
|
+
const mkEnv = (files) => ({ existingFiles: files });
|
|
4
|
+
describe("countExisting", () => {
|
|
5
|
+
it("counts files matching prefix", () => {
|
|
6
|
+
expect(countExisting(mkEnv(["extensions/a", "extensions/b", "prompts/c"]), "extensions")).toBe(2);
|
|
7
|
+
});
|
|
8
|
+
it("returns 0 when no match", () => {
|
|
9
|
+
expect(countExisting(mkEnv(["extensions/a"]), "themes")).toBe(0);
|
|
10
|
+
});
|
|
11
|
+
it("returns 0 for empty existingFiles", () => {
|
|
12
|
+
expect(countExisting(mkEnv([]), "extensions")).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
it("counts different prefix", () => {
|
|
15
|
+
expect(countExisting(mkEnv(["extensions/a", "extensions/b", "prompts/c"]), "prompts")).toBe(1);
|
|
16
|
+
});
|
|
17
|
+
it("does not match without slash separator", () => {
|
|
18
|
+
expect(countExisting(mkEnv(["extensionsX"]), "extensions")).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ProviderConfig } from "../types.js";
|
|
2
2
|
import type { EnvInfo } from "../utils/detect.js";
|
|
3
|
+
/** Block internal/private IPs to prevent SSRF */
|
|
4
|
+
export declare function isUnsafeUrl(urlStr: string): boolean;
|
|
3
5
|
/**
|
|
4
6
|
* Interactively configure API providers, detecting existing keys, allowing multi-select, and supporting custom endpoints.
|
|
5
7
|
* @param env - Current environment info with detected providers
|
|
@@ -13,7 +13,7 @@ const PROVIDER_API_URLS = {
|
|
|
13
13
|
mistral: "https://api.mistral.ai",
|
|
14
14
|
};
|
|
15
15
|
/** Block internal/private IPs to prevent SSRF */
|
|
16
|
-
function isUnsafeUrl(urlStr) {
|
|
16
|
+
export function isUnsafeUrl(urlStr) {
|
|
17
17
|
try {
|
|
18
18
|
const u = new URL(urlStr);
|
|
19
19
|
const host = u.hostname;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isUnsafeUrl } from "./provider-setup.js";
|
|
3
|
+
describe("isUnsafeUrl", () => {
|
|
4
|
+
it("https remote is safe", () => {
|
|
5
|
+
expect(isUnsafeUrl("https://api.example.com")).toBe(false);
|
|
6
|
+
});
|
|
7
|
+
it("http localhost is safe", () => {
|
|
8
|
+
expect(isUnsafeUrl("http://localhost:11434")).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it("http 127.0.0.1 is safe", () => {
|
|
11
|
+
expect(isUnsafeUrl("http://127.0.0.1:8080")).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it("http [::1] is blocked (contains colon in hostname)", () => {
|
|
14
|
+
expect(isUnsafeUrl("http://[::1]:8080")).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it("10.x private is unsafe", () => {
|
|
17
|
+
expect(isUnsafeUrl("https://10.0.0.1/api")).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it("172.16.x private is unsafe", () => {
|
|
20
|
+
expect(isUnsafeUrl("https://172.16.0.1/api")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
it("192.168.x private is unsafe", () => {
|
|
23
|
+
expect(isUnsafeUrl("https://192.168.1.1/api")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it("http remote is unsafe", () => {
|
|
26
|
+
expect(isUnsafeUrl("http://api.example.com")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it("0.0.0.0 is unsafe", () => {
|
|
29
|
+
expect(isUnsafeUrl("https://0.0.0.0")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it("169.254.x link-local is unsafe", () => {
|
|
32
|
+
expect(isUnsafeUrl("https://169.254.1.1")).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it("invalid url is unsafe", () => {
|
|
35
|
+
expect(isUnsafeUrl("not-a-url")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it("empty string is unsafe", () => {
|
|
38
|
+
expect(isUnsafeUrl("")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/dist/tui/welcome.d.ts
CHANGED
|
@@ -4,3 +4,9 @@ import type { EnvInfo } from "../utils/detect.js";
|
|
|
4
4
|
* @param {EnvInfo} env - 当前检测到的环境信息
|
|
5
5
|
*/
|
|
6
6
|
export declare function welcome(env: EnvInfo): void;
|
|
7
|
+
/**
|
|
8
|
+
* 按顶层目录分类统计文件数量,返回格式化字符串。
|
|
9
|
+
* @param {string[]} files - 文件相对路径列表
|
|
10
|
+
* @returns {string} 分类统计字符串,如 "extensions (3) prompts (5)"
|
|
11
|
+
*/
|
|
12
|
+
export declare function categorize(files: string[]): string;
|
package/dist/tui/welcome.js
CHANGED
|
@@ -28,7 +28,7 @@ export function welcome(env) {
|
|
|
28
28
|
* @param {string[]} files - 文件相对路径列表
|
|
29
29
|
* @returns {string} 分类统计字符串,如 "extensions (3) prompts (5)"
|
|
30
30
|
*/
|
|
31
|
-
function categorize(files) {
|
|
31
|
+
export function categorize(files) {
|
|
32
32
|
const cats = {};
|
|
33
33
|
for (const f of files) {
|
|
34
34
|
const cat = f.includes("/") ? f.split("/")[0] : f;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { categorize } from "./welcome.js";
|
|
3
|
+
describe("categorize", () => {
|
|
4
|
+
it("groups by directory", () => {
|
|
5
|
+
const result = categorize(["ext/a.ts", "ext/b.ts", "prompts/c.md"]);
|
|
6
|
+
expect(result).toContain("ext (2)");
|
|
7
|
+
expect(result).toContain("prompts (1)");
|
|
8
|
+
});
|
|
9
|
+
it("empty array returns empty string", () => {
|
|
10
|
+
expect(categorize([])).toBe("");
|
|
11
|
+
});
|
|
12
|
+
it("files without directory use filename", () => {
|
|
13
|
+
const result = categorize(["a.ts", "b.ts"]);
|
|
14
|
+
expect(result).toContain("a.ts (1)");
|
|
15
|
+
expect(result).toContain("b.ts (1)");
|
|
16
|
+
});
|
|
17
|
+
it("single file", () => {
|
|
18
|
+
expect(categorize(["foo/bar.ts"])).toBe("foo (1)");
|
|
19
|
+
});
|
|
20
|
+
it("mixed files with and without directory", () => {
|
|
21
|
+
const result = categorize(["a.ts", "dir/b.ts"]);
|
|
22
|
+
expect(result).toContain("a.ts (1)");
|
|
23
|
+
expect(result).toContain("dir (1)");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resources } from "./resources.js";
|
|
3
|
+
describe("resources", () => {
|
|
4
|
+
it("agent returns correct path", () => {
|
|
5
|
+
const p = resources.agent("foo");
|
|
6
|
+
expect(p).toContain("agents/foo.md");
|
|
7
|
+
expect(p.startsWith("/")).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
it("extension returns correct path", () => {
|
|
10
|
+
const p = resources.extension("bar");
|
|
11
|
+
expect(p).toContain("extensions/bar");
|
|
12
|
+
expect(p.startsWith("/")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it("extensionFile returns correct path", () => {
|
|
15
|
+
const p = resources.extensionFile("baz");
|
|
16
|
+
expect(p).toContain("extensions/baz.ts");
|
|
17
|
+
expect(p.startsWith("/")).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it("prompt returns correct path", () => {
|
|
20
|
+
const p = resources.prompt("test");
|
|
21
|
+
expect(p).toContain("prompts/test.md");
|
|
22
|
+
expect(p.startsWith("/")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it("skill returns correct path", () => {
|
|
25
|
+
const p = resources.skill("sk");
|
|
26
|
+
expect(p).toContain("skills/sk");
|
|
27
|
+
expect(p.startsWith("/")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it("skillsDir returns correct path", () => {
|
|
30
|
+
const p = resources.skillsDir();
|
|
31
|
+
expect(p).toContain("skills");
|
|
32
|
+
expect(p.startsWith("/")).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it("theme returns correct path", () => {
|
|
35
|
+
const p = resources.theme("dark");
|
|
36
|
+
expect(p).toContain("themes/dark.json");
|
|
37
|
+
expect(p.startsWith("/")).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.71",
|
|
4
4
|
"description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"oh-pi": "
|
|
7
|
+
"oh-pi": "dist/bin/oh-pi.js"
|
|
8
8
|
},
|
|
9
9
|
"main": "./dist/index.js",
|
|
10
10
|
"files": [
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"build": "tsc",
|
|
17
17
|
"dev": "tsc --watch",
|
|
18
18
|
"start": "node dist/bin/oh-pi.js",
|
|
19
|
+
"test": "vitest run",
|
|
19
20
|
"prepublishOnly": "npm run build"
|
|
20
21
|
},
|
|
21
22
|
"keywords": [
|
|
@@ -46,7 +47,8 @@
|
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/node": "^22.0.0",
|
|
49
|
-
"typescript": "^5.7.0"
|
|
50
|
+
"typescript": "^5.7.0",
|
|
51
|
+
"vitest": "^3.0.0"
|
|
50
52
|
},
|
|
51
53
|
"engines": {
|
|
52
54
|
"node": ">=20.0.0"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { defaultConcurrency, adapt } from "./concurrency.js";
|
|
3
|
+
import type { ConcurrencyConfig, ConcurrencySample } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const mkSample = (o: Partial<ConcurrencySample> = {}): ConcurrencySample => ({
|
|
6
|
+
timestamp: Date.now(), concurrency: 2, cpuLoad: 0.3, memFree: 4e9, throughput: 1, ...o,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("defaultConcurrency", () => {
|
|
10
|
+
it("returns valid config", () => {
|
|
11
|
+
const c = defaultConcurrency();
|
|
12
|
+
expect(c.current).toBe(2);
|
|
13
|
+
expect(c.min).toBe(1);
|
|
14
|
+
expect(c.max).toBeGreaterThanOrEqual(1);
|
|
15
|
+
expect(c.max).toBeLessThanOrEqual(8);
|
|
16
|
+
expect(c.history).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("adapt", () => {
|
|
21
|
+
it("drops to min when no pending tasks", () => {
|
|
22
|
+
const cfg: ConcurrencyConfig = { current: 4, min: 1, max: 8, optimal: 3, history: [mkSample(), mkSample()] };
|
|
23
|
+
expect(adapt(cfg, 0).current).toBe(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("cold start gives half max", () => {
|
|
27
|
+
const cfg: ConcurrencyConfig = { current: 2, min: 1, max: 8, optimal: 3, history: [mkSample()] };
|
|
28
|
+
expect(adapt(cfg, 10).current).toBe(4);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reduces when CPU > 85%", () => {
|
|
32
|
+
const s = mkSample({ cpuLoad: 0.9 });
|
|
33
|
+
const cfg: ConcurrencyConfig = { current: 4, min: 1, max: 8, optimal: 3, history: [s, s, s] };
|
|
34
|
+
expect(adapt(cfg, 10).current).toBeLessThan(4);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reduces when memory low", () => {
|
|
38
|
+
const s = mkSample({ memFree: 100 * 1024 * 1024 });
|
|
39
|
+
const cfg: ConcurrencyConfig = { current: 4, min: 1, max: 8, optimal: 3, history: [s, s] };
|
|
40
|
+
expect(adapt(cfg, 10).current).toBeLessThan(4);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("increases during exploration when throughput rising", () => {
|
|
44
|
+
const s1 = mkSample({ throughput: 1 });
|
|
45
|
+
const s2 = mkSample({ throughput: 2 });
|
|
46
|
+
const cfg: ConcurrencyConfig = { current: 3, min: 1, max: 8, optimal: 3, history: [s1, s2] };
|
|
47
|
+
expect(adapt(cfg, 10).current).toBeGreaterThanOrEqual(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not exceed max", () => {
|
|
51
|
+
const s1 = mkSample({ throughput: 1 });
|
|
52
|
+
const s2 = mkSample({ throughput: 5 });
|
|
53
|
+
const cfg: ConcurrencyConfig = { current: 8, min: 1, max: 8, optimal: 3, history: [s1, s2] };
|
|
54
|
+
expect(adapt(cfg, 100).current).toBeLessThanOrEqual(8);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not exceed pending task count", () => {
|
|
58
|
+
const s1 = mkSample({ throughput: 1 });
|
|
59
|
+
const s2 = mkSample({ throughput: 5 });
|
|
60
|
+
const cfg: ConcurrencyConfig = { current: 3, min: 1, max: 8, optimal: 3, history: [s1, s2] };
|
|
61
|
+
expect(adapt(cfg, 2).current).toBeLessThanOrEqual(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("respects rate limit cooldown", () => {
|
|
65
|
+
const s1 = mkSample({ throughput: 1 });
|
|
66
|
+
const s2 = mkSample({ throughput: 2 });
|
|
67
|
+
const cfg: ConcurrencyConfig = { current: 3, min: 1, max: 8, optimal: 3, history: [s1, s2], lastRateLimitAt: Date.now() };
|
|
68
|
+
expect(adapt(cfg, 10).current).toBeLessThanOrEqual(3);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildImportGraph, dependencyDepth, taskDependsOn } from "./deps.js";
|
|
3
|
+
import type { ImportGraph } from "./deps.js";
|
|
4
|
+
|
|
5
|
+
describe("buildImportGraph", () => {
|
|
6
|
+
it("returns empty graph for empty files", () => {
|
|
7
|
+
const graph = buildImportGraph([], "/tmp");
|
|
8
|
+
expect(graph.imports.size).toBe(0);
|
|
9
|
+
expect(graph.importedBy.size).toBe(0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns empty graph for nonexistent files", () => {
|
|
13
|
+
const graph = buildImportGraph(["nonexistent.ts"], "/tmp");
|
|
14
|
+
expect(graph.imports.size).toBe(0);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("dependencyDepth", () => {
|
|
19
|
+
it("returns 0 for file with no dependents", () => {
|
|
20
|
+
const graph: ImportGraph = { imports: new Map(), importedBy: new Map() };
|
|
21
|
+
expect(dependencyDepth("a.ts", graph)).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("counts direct dependents", () => {
|
|
25
|
+
const graph: ImportGraph = {
|
|
26
|
+
imports: new Map([["b.ts", new Set(["a.ts"])]]),
|
|
27
|
+
importedBy: new Map([["a.ts", new Set(["b.ts"])]]),
|
|
28
|
+
};
|
|
29
|
+
expect(dependencyDepth("a.ts", graph)).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("counts transitive dependents", () => {
|
|
33
|
+
const graph: ImportGraph = {
|
|
34
|
+
imports: new Map([["b.ts", new Set(["a.ts"])], ["c.ts", new Set(["b.ts"])]]),
|
|
35
|
+
importedBy: new Map([["a.ts", new Set(["b.ts"])], ["b.ts", new Set(["c.ts"])]]),
|
|
36
|
+
};
|
|
37
|
+
expect(dependencyDepth("a.ts", graph)).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("taskDependsOn", () => {
|
|
42
|
+
it("returns true when taskA imports taskB file", () => {
|
|
43
|
+
const graph: ImportGraph = {
|
|
44
|
+
imports: new Map([["a.ts", new Set(["b.ts"])]]),
|
|
45
|
+
importedBy: new Map([["b.ts", new Set(["a.ts"])]]),
|
|
46
|
+
};
|
|
47
|
+
expect(taskDependsOn(["a.ts"], ["b.ts"], graph)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns false when no dependency", () => {
|
|
51
|
+
const graph: ImportGraph = {
|
|
52
|
+
imports: new Map([["a.ts", new Set(["c.ts"])]]),
|
|
53
|
+
importedBy: new Map(),
|
|
54
|
+
};
|
|
55
|
+
expect(taskDependsOn(["a.ts"], ["b.ts"], graph)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns false for empty file lists", () => {
|
|
59
|
+
const graph: ImportGraph = { imports: new Map(), importedBy: new Map() };
|
|
60
|
+
expect(taskDependsOn([], [], graph)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -51,6 +51,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
51
51
|
|
|
52
52
|
// 当前运行中的后台蚁群(同时只允许一个)
|
|
53
53
|
let activeColony: BackgroundColony | null = null;
|
|
54
|
+
let uiListenersRegistered = false;
|
|
54
55
|
|
|
55
56
|
// ─── Status 渲染 ───
|
|
56
57
|
|
|
@@ -64,6 +65,9 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
64
65
|
|
|
65
66
|
// 监听事件来更新 UI(确保在有 ctx 的上下文中)
|
|
66
67
|
pi.on("session_start", async (_event, ctx) => {
|
|
68
|
+
if (uiListenersRegistered) return;
|
|
69
|
+
uiListenersRegistered = true;
|
|
70
|
+
|
|
67
71
|
pi.events.on("ant-colony:render", () => {
|
|
68
72
|
if (!activeColony) return;
|
|
69
73
|
const { state } = activeColony;
|
|
@@ -24,6 +24,7 @@ export class Nest {
|
|
|
24
24
|
private stateCache: ColonyState | null = null;
|
|
25
25
|
private gcCounter: number = 0;
|
|
26
26
|
private pheromoneByFile: Map<string, Pheromone[]> = new Map();
|
|
27
|
+
private pheromoneIndexDirty: boolean = true;
|
|
27
28
|
|
|
28
29
|
constructor(private cwd: string, private colonyId: string) {
|
|
29
30
|
this.dir = path.join(cwd, ".ant-colony", colonyId);
|
|
@@ -53,6 +54,14 @@ export class Nest {
|
|
|
53
54
|
return base;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/** 轻量版 getState:只返回 stateCache + tasks,不触发 pheromone 读取 */
|
|
58
|
+
getStateLight(): ColonyState {
|
|
59
|
+
if (!this.stateCache) {
|
|
60
|
+
this.stateCache = this.readJson<ColonyState>(this.stateFile);
|
|
61
|
+
}
|
|
62
|
+
return { ...this.stateCache, tasks: this.getAllTasks() };
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
updateState(patch: Partial<Pick<ColonyState, "status" | "concurrency" | "metrics" | "ants" | "finishedAt">>): void {
|
|
57
66
|
this.withStateLock(() => {
|
|
58
67
|
if (!this.stateCache) {
|
|
@@ -158,43 +167,11 @@ export class Nest {
|
|
|
158
167
|
}
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
/** 获取下一个可领取的任务(按优先级 + 信息素强度 - repellent负信息素排序,ε-greedy 随机觅食) */
|
|
162
|
-
nextPendingTask(caste: "scout" | "worker" | "soldier"): Task | null {
|
|
163
|
-
const tasks = this.getAllTasks()
|
|
164
|
-
.filter(t => t.status === "pending" && t.caste === caste);
|
|
165
|
-
if (tasks.length === 0) return null;
|
|
166
|
-
|
|
167
|
-
// ε-greedy:10% 概率随机选任务,避免蚂蚁全挤同一条路
|
|
168
|
-
if (tasks.length > 1 && Math.random() < 0.1) {
|
|
169
|
-
return tasks[Math.floor(Math.random() * tasks.length)];
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// 信息素加权:用索引查询而非全量扫描
|
|
173
|
-
this.getAllPheromones(); // 确保索引已建立
|
|
174
|
-
const scored = tasks.map(t => {
|
|
175
|
-
let pScore = 0;
|
|
176
|
-
const seen = new Set<Pheromone>();
|
|
177
|
-
for (const f of t.files) {
|
|
178
|
-
const related = this.pheromoneByFile.get(f);
|
|
179
|
-
if (!related) continue;
|
|
180
|
-
for (const p of related) {
|
|
181
|
-
if (seen.has(p) || p.strength <= 0.1) continue;
|
|
182
|
-
seen.add(p);
|
|
183
|
-
if (p.type === "discovery" || p.type === "completion") pScore += p.strength;
|
|
184
|
-
else if (p.type === "repellent") pScore -= p.strength * 3;
|
|
185
|
-
else if (p.type === "warning") pScore -= p.strength;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return { task: t, score: (6 - t.priority) + pScore };
|
|
189
|
-
});
|
|
190
|
-
scored.sort((a, b) => b.score - a.score);
|
|
191
|
-
return scored[0]?.task ?? null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
170
|
// ═══ Pheromones ═══
|
|
195
171
|
|
|
196
172
|
dropPheromone(p: Pheromone): void {
|
|
197
173
|
fs.appendFileSync(this.pheromoneFile, JSON.stringify(p) + "\n");
|
|
174
|
+
this.pheromoneIndexDirty = true;
|
|
198
175
|
}
|
|
199
176
|
|
|
200
177
|
getAllPheromones(): Pheromone[] {
|
|
@@ -223,15 +200,19 @@ export class Nest {
|
|
|
223
200
|
return p.strength > 0.05;
|
|
224
201
|
});
|
|
225
202
|
const hadGarbage = this.pheromoneCache.length < beforeLen;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
203
|
+
if (hadGarbage) this.pheromoneIndexDirty = true;
|
|
204
|
+
|
|
205
|
+
// 重建文件索引(仅在 dirty 时)
|
|
206
|
+
if (this.pheromoneIndexDirty) {
|
|
207
|
+
this.pheromoneByFile.clear();
|
|
208
|
+
for (const p of this.pheromoneCache) {
|
|
209
|
+
for (const f of p.files) {
|
|
210
|
+
let arr = this.pheromoneByFile.get(f);
|
|
211
|
+
if (!arr) { arr = []; this.pheromoneByFile.set(f, arr); }
|
|
212
|
+
arr.push(p);
|
|
213
|
+
}
|
|
234
214
|
}
|
|
215
|
+
this.pheromoneIndexDirty = false;
|
|
235
216
|
}
|
|
236
217
|
|
|
237
218
|
// GC:每 10 次调用,若有弱条目被过滤则重写文件
|
|
@@ -298,7 +279,7 @@ export class Nest {
|
|
|
298
279
|
|
|
299
280
|
private withStateLock<T>(fn: () => T): T {
|
|
300
281
|
const MAX_WAIT = 3000;
|
|
301
|
-
const SPIN_MS =
|
|
282
|
+
const SPIN_MS = 5;
|
|
302
283
|
const start = Date.now();
|
|
303
284
|
while (true) {
|
|
304
285
|
try {
|
|
@@ -319,8 +300,8 @@ export class Nest {
|
|
|
319
300
|
// 进程存活且锁未过期,放弃等待
|
|
320
301
|
throw new Error(`[Nest] withStateLock timeout after ${MAX_WAIT}ms`);
|
|
321
302
|
}
|
|
322
|
-
// 简单 busy-wait,避免 SharedArrayBuffer 依赖
|
|
323
|
-
const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS;
|
|
303
|
+
// 简单 busy-wait + jitter,避免 SharedArrayBuffer 依赖
|
|
304
|
+
const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS * 2;
|
|
324
305
|
while (Date.now() < until) { /* spin */ }
|
|
325
306
|
}
|
|
326
307
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
4
|
+
AuthStorage: class {},
|
|
5
|
+
createAgentSession: vi.fn(),
|
|
6
|
+
createReadTool: vi.fn(), createBashTool: vi.fn(), createEditTool: vi.fn(),
|
|
7
|
+
createWriteTool: vi.fn(), createGrepTool: vi.fn(), createFindTool: vi.fn(),
|
|
8
|
+
createLsTool: vi.fn(), ModelRegistry: class {}, SessionManager: { inMemory: vi.fn() },
|
|
9
|
+
SettingsManager: { inMemory: vi.fn() }, createExtensionRuntime: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("@mariozechner/pi-ai", () => ({ getModel: vi.fn() }));
|
|
12
|
+
vi.mock("./spawner.js", async () => {
|
|
13
|
+
const actual = await vi.importActual<any>("./spawner.js");
|
|
14
|
+
return { ...actual, makePheromoneId: () => "p-test" };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
import { parseSubTasks, extractPheromones } from "./parser.js";
|
|
18
|
+
|
|
19
|
+
describe("parseSubTasks", () => {
|
|
20
|
+
it("parses markdown TASK blocks", () => {
|
|
21
|
+
const output = `## Recommended Tasks
|
|
22
|
+
### TASK: Fix login
|
|
23
|
+
- description: Fix the login bug
|
|
24
|
+
- files: src/auth.ts
|
|
25
|
+
- caste: worker
|
|
26
|
+
- priority: 2`;
|
|
27
|
+
const tasks = parseSubTasks(output);
|
|
28
|
+
expect(tasks).toHaveLength(1);
|
|
29
|
+
expect(tasks[0].title).toBe("Fix login");
|
|
30
|
+
expect(tasks[0].description).toBe("Fix the login bug");
|
|
31
|
+
expect(tasks[0].files).toEqual(["src/auth.ts"]);
|
|
32
|
+
expect(tasks[0].caste).toBe("worker");
|
|
33
|
+
expect(tasks[0].priority).toBe(2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("parses JSON block", () => {
|
|
37
|
+
const output = '```json\n[{"title":"Task A","description":"Do A","files":["a.ts"],"caste":"scout","priority":1}]\n```';
|
|
38
|
+
const tasks = parseSubTasks(output);
|
|
39
|
+
expect(tasks).toHaveLength(1);
|
|
40
|
+
expect(tasks[0].title).toBe("Task A");
|
|
41
|
+
expect(tasks[0].caste).toBe("scout");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("defaults caste to worker for invalid", () => {
|
|
45
|
+
const tasks = parseSubTasks('```json\n[{"title":"X","caste":"invalid"}]\n```');
|
|
46
|
+
expect(tasks[0].caste).toBe("worker");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("defaults priority to 3", () => {
|
|
50
|
+
const tasks = parseSubTasks('```json\n[{"title":"X"}]\n```');
|
|
51
|
+
expect(tasks[0].priority).toBe(3);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns empty for no tasks", () => {
|
|
55
|
+
expect(parseSubTasks("no tasks here")).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("parses multiple markdown tasks", () => {
|
|
59
|
+
const output = `### TASK: A
|
|
60
|
+
- description: Do A
|
|
61
|
+
- files: a.ts
|
|
62
|
+
- caste: worker
|
|
63
|
+
- priority: 1
|
|
64
|
+
|
|
65
|
+
### TASK: B
|
|
66
|
+
- description: Do B
|
|
67
|
+
- files: b.ts
|
|
68
|
+
- caste: soldier
|
|
69
|
+
- priority: 2`;
|
|
70
|
+
const tasks = parseSubTasks(output);
|
|
71
|
+
expect(tasks).toHaveLength(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("parses context field", () => {
|
|
75
|
+
const output = `### TASK: Fix it
|
|
76
|
+
- description: Fix bug
|
|
77
|
+
- files: x.ts
|
|
78
|
+
- caste: worker
|
|
79
|
+
- priority: 3
|
|
80
|
+
- context: some relevant code`;
|
|
81
|
+
const tasks = parseSubTasks(output);
|
|
82
|
+
expect(tasks[0].context).toBeTruthy();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("extractPheromones", () => {
|
|
87
|
+
it("extracts discovery section", () => {
|
|
88
|
+
const p = extractPheromones("ant-1", "scout", "t-1", "## Discoveries\n- Found auth\n\n## Other\nstuff", ["a.ts"]);
|
|
89
|
+
expect(p.some(x => x.type === "discovery")).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("extracts warning section", () => {
|
|
93
|
+
const p = extractPheromones("ant-1", "scout", "t-1", "## Warnings\n- Conflict\n", []);
|
|
94
|
+
expect(p.some(x => x.type === "warning")).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("adds repellent on failure", () => {
|
|
98
|
+
const p = extractPheromones("ant-1", "worker", "t-1", "output", ["a.ts"], true);
|
|
99
|
+
expect(p.some(x => x.type === "repellent")).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns empty for no matching sections", () => {
|
|
103
|
+
expect(extractPheromones("ant-1", "worker", "t-1", "nothing", [])).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("extracts Files Changed as completion", () => {
|
|
107
|
+
const p = extractPheromones("ant-1", "worker", "t-1", "## Files Changed\n- src/foo.ts\n", ["src/foo.ts"]);
|
|
108
|
+
expect(p.some(x => x.type === "completion")).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { CASTE_PROMPTS, buildPrompt } from "./prompts.js";
|
|
3
|
+
import type { Task } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const mkTask = (overrides: Partial<Task> = {}): Task => ({
|
|
6
|
+
id: "t-1", parentId: null, title: "Test task", description: "Do something",
|
|
7
|
+
caste: "worker", status: "pending", priority: 3, files: [], claimedBy: null,
|
|
8
|
+
result: null, error: null, spawnedTasks: [], createdAt: 0, startedAt: null, finishedAt: null,
|
|
9
|
+
...overrides,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("CASTE_PROMPTS", () => {
|
|
13
|
+
it("has all castes", () => {
|
|
14
|
+
for (const c of ["scout", "worker", "soldier"] as const) {
|
|
15
|
+
expect(typeof CASTE_PROMPTS[c]).toBe("string");
|
|
16
|
+
expect(CASTE_PROMPTS[c].length).toBeGreaterThan(0);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("buildPrompt", () => {
|
|
22
|
+
it("includes task title and description", () => {
|
|
23
|
+
const r = buildPrompt(mkTask(), "", "System");
|
|
24
|
+
expect(r).toContain("Test task");
|
|
25
|
+
expect(r).toContain("Do something");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("includes pheromone context", () => {
|
|
29
|
+
const r = buildPrompt(mkTask(), "Found auth at src/auth.ts", "System");
|
|
30
|
+
expect(r).toContain("Found auth at src/auth.ts");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("includes files scope", () => {
|
|
34
|
+
const r = buildPrompt(mkTask({ files: ["a.ts", "b.ts"] }), "", "System");
|
|
35
|
+
expect(r).toContain("a.ts, b.ts");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("includes turn limit when provided", () => {
|
|
39
|
+
const r = buildPrompt(mkTask(), "", "System", 10);
|
|
40
|
+
expect(r).toContain("10");
|
|
41
|
+
expect(r).toContain("Turn Limit");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("omits turn limit when not provided", () => {
|
|
45
|
+
expect(buildPrompt(mkTask(), "", "System")).not.toContain("Turn Limit");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("includes pre-loaded context", () => {
|
|
49
|
+
const r = buildPrompt(mkTask({ context: "code snippet" }), "", "System");
|
|
50
|
+
expect(r).toContain("code snippet");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("adds Chinese hint for Chinese descriptions", () => {
|
|
54
|
+
const r = buildPrompt(mkTask({ description: "修复登录问题" }), "", "System");
|
|
55
|
+
expect(r).toContain("Chinese");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
} from "./types.js";
|
|
21
21
|
import { DEFAULT_ANT_CONFIGS } from "./types.js";
|
|
22
22
|
import { Nest } from "./nest.js";
|
|
23
|
-
import { spawnAnt, runDrone, makeTaskId, makePheromoneId } from "./spawner.js";
|
|
23
|
+
import { spawnAnt, runDrone, makeTaskId, makePheromoneId, resetAntCounter } from "./spawner.js";
|
|
24
24
|
import { adapt, sampleSystem, defaultConcurrency } from "./concurrency.js";
|
|
25
25
|
import { buildImportGraph, taskDependsOn, type ImportGraph } from "./deps.js";
|
|
26
26
|
import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
|
@@ -120,8 +120,8 @@ function makeReviewTask(completedTasks: Task[]): Task {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function updateMetrics(nest: Nest): ColonyMetrics {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
123
|
+
const state = nest.getStateLight();
|
|
124
|
+
const tasks = state.tasks;
|
|
125
125
|
const now = Date.now();
|
|
126
126
|
const elapsed = (now - state.metrics.startTime) / 60000; // minutes
|
|
127
127
|
|
|
@@ -172,7 +172,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
172
172
|
|
|
173
173
|
const runOne = async (): Promise<"done" | "empty" | "rate_limited" | "budget"> => {
|
|
174
174
|
// Budget 刹车:预算用完就不出发(drone 免费,不检查)
|
|
175
|
-
const state = nest.
|
|
175
|
+
const state = nest.getStateLight();
|
|
176
176
|
if (state.maxCost != null && caste !== "drone") {
|
|
177
177
|
const spent = state.ants.reduce((s, a) => s + a.usage.cost, 0);
|
|
178
178
|
if (spent >= state.maxCost) return "budget";
|
|
@@ -211,7 +211,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
// 成本预警:超 80% 预算时发信号
|
|
214
|
-
const curState = nest.
|
|
214
|
+
const curState = nest.getStateLight();
|
|
215
215
|
if (curState.maxCost != null) {
|
|
216
216
|
const spent = curState.ants.reduce((s, a) => s + a.usage.cost, 0);
|
|
217
217
|
if (spent >= curState.maxCost * 0.8) {
|
|
@@ -282,7 +282,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
282
282
|
// 调度循环:持续派蚂蚁直到没有待处理任务
|
|
283
283
|
let lastSampleTime = 0;
|
|
284
284
|
while (!signal?.aborted) {
|
|
285
|
-
const state = nest.
|
|
285
|
+
const state = nest.getStateLight();
|
|
286
286
|
const pending = state.tasks.filter(t => t.status === "pending" && t.caste === caste);
|
|
287
287
|
if (pending.length === 0) break;
|
|
288
288
|
|
|
@@ -347,7 +347,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
347
347
|
// 429 处理:降低并发 + 渐进退避(2s → 5s → 10s,上限 10s)+ 记录时间戳
|
|
348
348
|
if (results.includes("rate_limited")) {
|
|
349
349
|
consecutiveRateLimits++;
|
|
350
|
-
const cur = nest.
|
|
350
|
+
const cur = nest.getStateLight().concurrency;
|
|
351
351
|
const reduced = Math.max(cur.min, cur.current - 1); // 每次只减 1,不砍半
|
|
352
352
|
nest.updateState({ concurrency: { ...cur, current: reduced, lastRateLimitAt: Date.now() } });
|
|
353
353
|
backoffMs = Math.min(consecutiveRateLimits * 2000, 10000);
|
|
@@ -366,6 +366,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
366
366
|
if (!opts.goal || !opts.goal.trim()) {
|
|
367
367
|
throw new Error("Colony goal is empty or undefined. Please provide a clear goal.");
|
|
368
368
|
}
|
|
369
|
+
resetAntCounter();
|
|
369
370
|
const colonyId = makeColonyId();
|
|
370
371
|
const nest = new Nest(opts.cwd, colonyId);
|
|
371
372
|
|
|
@@ -405,7 +406,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
405
406
|
};
|
|
406
407
|
|
|
407
408
|
const emitSignal = (phase: ColonyState["status"], message: string) => {
|
|
408
|
-
const state = nest.
|
|
409
|
+
const state = nest.getStateLight();
|
|
409
410
|
const m = state.metrics;
|
|
410
411
|
const active = state.ants.filter(a => a.status === "working").length;
|
|
411
412
|
const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
|
|
@@ -516,8 +517,8 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
516
517
|
const discoveries = nest.getAllPheromones().filter(p => p.type === "discovery");
|
|
517
518
|
const allDone = nest.getAllTasks().filter(t => t.status === "done");
|
|
518
519
|
if (discoveries.length > allDone.length) {
|
|
519
|
-
const spent = nest.
|
|
520
|
-
if (spent < (nest.
|
|
520
|
+
const spent = nest.getStateLight().ants.reduce((s, a) => s + a.usage.cost, 0);
|
|
521
|
+
if (spent < (nest.getStateLight().maxCost ?? Infinity)) {
|
|
521
522
|
callbacks.onPhase?.("scouting", "Re-exploring based on new discoveries...");
|
|
522
523
|
emitSignal("scouting", "Re-exploring...");
|
|
523
524
|
await runAntWave({ ...waveBase, caste: "scout" });
|
|
@@ -606,7 +607,7 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
606
607
|
const { signal, callbacks } = opts;
|
|
607
608
|
|
|
608
609
|
const emitSignal = (phase: ColonyState["status"], message: string) => {
|
|
609
|
-
const state = nest.
|
|
610
|
+
const state = nest.getStateLight();
|
|
610
611
|
const m = state.metrics;
|
|
611
612
|
const active = state.ants.filter(a => a.status === "working").length;
|
|
612
613
|
const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
4
|
+
AuthStorage: class {},
|
|
5
|
+
createAgentSession: vi.fn(),
|
|
6
|
+
createReadTool: vi.fn(), createBashTool: vi.fn(), createEditTool: vi.fn(),
|
|
7
|
+
createWriteTool: vi.fn(), createGrepTool: vi.fn(), createFindTool: vi.fn(),
|
|
8
|
+
createLsTool: vi.fn(), ModelRegistry: class {}, SessionManager: { inMemory: vi.fn() },
|
|
9
|
+
SettingsManager: { inMemory: vi.fn() }, createExtensionRuntime: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("@mariozechner/pi-ai", () => ({ getModel: vi.fn() }));
|
|
12
|
+
|
|
13
|
+
import { makeAntId, makePheromoneId, makeTaskId } from "./spawner.js";
|
|
14
|
+
|
|
15
|
+
describe("makeAntId", () => {
|
|
16
|
+
it("includes caste name", () => {
|
|
17
|
+
expect(makeAntId("scout")).toContain("scout");
|
|
18
|
+
expect(makeAntId("worker")).toContain("worker");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns unique ids", () => {
|
|
22
|
+
expect(makeAntId("worker")).not.toBe(makeAntId("worker"));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("makePheromoneId", () => {
|
|
27
|
+
it("starts with p-", () => {
|
|
28
|
+
expect(makePheromoneId()).toMatch(/^p-/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns unique ids", () => {
|
|
32
|
+
expect(makePheromoneId()).not.toBe(makePheromoneId());
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("makeTaskId", () => {
|
|
37
|
+
it("starts with t-", () => {
|
|
38
|
+
expect(makeTaskId()).toMatch(/^t-/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns unique ids", () => {
|
|
42
|
+
expect(makeTaskId()).not.toBe(makeTaskId());
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -32,6 +32,8 @@ import { parseSubTasks, extractPheromones, type ParsedSubTask } from "./parser.j
|
|
|
32
32
|
|
|
33
33
|
let antCounter = 0;
|
|
34
34
|
|
|
35
|
+
export function resetAntCounter(): void { antCounter = 0; }
|
|
36
|
+
|
|
35
37
|
export function makeAntId(caste: AntCaste): string {
|
|
36
38
|
return `${caste}-${++antCounter}-${Date.now().toString(36)}`;
|
|
37
39
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { DEFAULT_ANT_CONFIGS } from "./types.js";
|
|
3
|
+
import type { AntCaste } from "./types.js";
|
|
4
|
+
|
|
5
|
+
describe("DEFAULT_ANT_CONFIGS", () => {
|
|
6
|
+
const castes: AntCaste[] = ["scout", "worker", "soldier", "drone"];
|
|
7
|
+
|
|
8
|
+
it("has all castes", () => {
|
|
9
|
+
for (const c of castes) expect(DEFAULT_ANT_CONFIGS).toHaveProperty(c);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("each config has caste/model/tools/maxTurns", () => {
|
|
13
|
+
for (const c of castes) {
|
|
14
|
+
const cfg = DEFAULT_ANT_CONFIGS[c];
|
|
15
|
+
expect(cfg.caste).toBe(c);
|
|
16
|
+
expect(typeof cfg.model).toBe("string");
|
|
17
|
+
expect(Array.isArray(cfg.tools)).toBe(true);
|
|
18
|
+
expect(cfg.maxTurns).toBeGreaterThan(0);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("scout has no write tools", () => {
|
|
23
|
+
expect(DEFAULT_ANT_CONFIGS.scout.tools).not.toContain("edit");
|
|
24
|
+
expect(DEFAULT_ANT_CONFIGS.scout.tools).not.toContain("write");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("worker has edit and write", () => {
|
|
28
|
+
expect(DEFAULT_ANT_CONFIGS.worker.tools).toContain("edit");
|
|
29
|
+
expect(DEFAULT_ANT_CONFIGS.worker.tools).toContain("write");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drone only has bash with 1 turn", () => {
|
|
33
|
+
expect(DEFAULT_ANT_CONFIGS.drone.tools).toEqual(["bash"]);
|
|
34
|
+
expect(DEFAULT_ANT_CONFIGS.drone.maxTurns).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon, buildReport } from "./ui.js";
|
|
3
|
+
import type { ColonyState } from "./types.js";
|
|
4
|
+
|
|
5
|
+
describe("formatDuration", () => {
|
|
6
|
+
it("0ms", () => expect(formatDuration(0)).toBe("0s"));
|
|
7
|
+
it("5000ms", () => expect(formatDuration(5000)).toBe("5s"));
|
|
8
|
+
it("59000ms", () => expect(formatDuration(59000)).toBe("59s"));
|
|
9
|
+
it("60000ms", () => expect(formatDuration(60000)).toBe("1m0s"));
|
|
10
|
+
it("90000ms", () => expect(formatDuration(90000)).toBe("1m30s"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("formatCost", () => {
|
|
14
|
+
it("0.001", () => expect(formatCost(0.001)).toBe("$0.0010"));
|
|
15
|
+
it("0.009", () => expect(formatCost(0.009)).toBe("$0.0090"));
|
|
16
|
+
it("0.01", () => expect(formatCost(0.01)).toBe("$0.01"));
|
|
17
|
+
it("1.5", () => expect(formatCost(1.5)).toBe("$1.50"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("formatTokens", () => {
|
|
21
|
+
it("500", () => expect(formatTokens(500)).toBe("500"));
|
|
22
|
+
it("999", () => expect(formatTokens(999)).toBe("999"));
|
|
23
|
+
it("1500", () => expect(formatTokens(1500)).toBe("1.5k"));
|
|
24
|
+
it("1500000", () => expect(formatTokens(1500000)).toBe("1.5M"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("statusIcon", () => {
|
|
28
|
+
it("scouting", () => expect(statusIcon("scouting")).toBe("🔍"));
|
|
29
|
+
it("working", () => expect(statusIcon("working")).toBe("⚒️"));
|
|
30
|
+
it("reviewing", () => expect(statusIcon("reviewing")).toBe("🛡️"));
|
|
31
|
+
it("done", () => expect(statusIcon("done")).toBe("✅"));
|
|
32
|
+
it("failed", () => expect(statusIcon("failed")).toBe("❌"));
|
|
33
|
+
it("budget_exceeded", () => expect(statusIcon("budget_exceeded")).toBe("💰"));
|
|
34
|
+
it("unknown", () => expect(statusIcon("xyz")).toBe("🐜"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("casteIcon", () => {
|
|
38
|
+
it("scout", () => expect(casteIcon("scout")).toBe("🔍"));
|
|
39
|
+
it("soldier", () => expect(casteIcon("soldier")).toBe("🛡️"));
|
|
40
|
+
it("drone", () => expect(casteIcon("drone")).toBe("⚙️"));
|
|
41
|
+
it("worker", () => expect(casteIcon("worker")).toBe("⚒️"));
|
|
42
|
+
it("unknown", () => expect(casteIcon("xyz")).toBe("⚒️"));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("buildReport", () => {
|
|
46
|
+
it("builds report with goal, status, cost, tasks", () => {
|
|
47
|
+
const state: ColonyState = {
|
|
48
|
+
id: "c-1", goal: "Test goal", status: "done",
|
|
49
|
+
tasks: [
|
|
50
|
+
{ id: "t1", parentId: null, title: "Task A", description: "", caste: "worker", status: "done", priority: 3, files: [], claimedBy: null, result: null, error: null, spawnedTasks: [], createdAt: 0, startedAt: 0, finishedAt: 1000 },
|
|
51
|
+
{ id: "t2", parentId: null, title: "Task B", description: "", caste: "worker", status: "failed", priority: 3, files: [], claimedBy: null, result: null, error: "some error", spawnedTasks: [], createdAt: 0, startedAt: 0, finishedAt: 1000 },
|
|
52
|
+
],
|
|
53
|
+
ants: [], pheromones: [],
|
|
54
|
+
concurrency: { current: 2, min: 1, max: 4, optimal: 3, history: [] },
|
|
55
|
+
metrics: { tasksTotal: 2, tasksDone: 1, tasksFailed: 1, antsSpawned: 2, totalCost: 0.05, totalTokens: 1000, startTime: 0, throughputHistory: [] },
|
|
56
|
+
maxCost: null, modelOverrides: {}, createdAt: 0, finishedAt: 5000,
|
|
57
|
+
};
|
|
58
|
+
const report = buildReport(state);
|
|
59
|
+
expect(report).toContain("Test goal");
|
|
60
|
+
expect(report).toContain("✅");
|
|
61
|
+
expect(report).toContain("$0.05");
|
|
62
|
+
expect(report).toContain("Task A");
|
|
63
|
+
expect(report).toContain("Task B");
|
|
64
|
+
expect(report).toContain("some error");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({}));
|
|
4
|
+
|
|
5
|
+
import { isNewer } from "./auto-update";
|
|
6
|
+
|
|
7
|
+
describe("isNewer", () => {
|
|
8
|
+
it("patch newer", () => expect(isNewer("1.0.1", "1.0.0")).toBe(true));
|
|
9
|
+
it("minor newer", () => expect(isNewer("1.1.0", "1.0.0")).toBe(true));
|
|
10
|
+
it("major newer", () => expect(isNewer("2.0.0", "1.9.9")).toBe(true));
|
|
11
|
+
it("equal", () => expect(isNewer("1.0.0", "1.0.0")).toBe(false));
|
|
12
|
+
it("older", () => expect(isNewer("1.0.0", "1.0.1")).toBe(false));
|
|
13
|
+
it("0.1.70 > 0.1.69", () => expect(isNewer("0.1.70", "0.1.69")).toBe(true));
|
|
14
|
+
it("0.1.69 < 0.1.70", () => expect(isNewer("0.1.69", "0.1.70")).toBe(false));
|
|
15
|
+
});
|
|
@@ -39,7 +39,7 @@ function getCurrentVersion(): string | null {
|
|
|
39
39
|
} catch { return null; }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function isNewer(latest: string, current: string): boolean {
|
|
42
|
+
export function isNewer(latest: string, current: string): boolean {
|
|
43
43
|
const a = latest.split(".").map(Number);
|
|
44
44
|
const b = current.split(".").map(Number);
|
|
45
45
|
for (let i = 0; i < 3; i++) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { DANGEROUS_PATTERNS, PROTECTED_PATHS } from "./safe-guard";
|
|
3
|
+
|
|
4
|
+
describe("DANGEROUS_PATTERNS", () => {
|
|
5
|
+
const matches = (cmd: string) => DANGEROUS_PATTERNS.some((p) => p.test(cmd));
|
|
6
|
+
|
|
7
|
+
it("matches rm -rf /", () => expect(matches("rm -rf /")).toBe(true));
|
|
8
|
+
it("matches rm -f file.txt", () => expect(matches("rm -f file.txt")).toBe(true));
|
|
9
|
+
it("matches rm --force file", () => expect(matches("rm --force file")).toBe(true));
|
|
10
|
+
it("matches sudo rm file", () => expect(matches("sudo rm file")).toBe(true));
|
|
11
|
+
it("matches DROP TABLE users", () => expect(matches("DROP TABLE users")).toBe(true));
|
|
12
|
+
it("matches TRUNCATE TABLE foo", () => expect(matches("TRUNCATE TABLE foo")).toBe(true));
|
|
13
|
+
it("matches DELETE FROM users", () => expect(matches("DELETE FROM users")).toBe(true));
|
|
14
|
+
it("matches chmod 777 /tmp", () => expect(matches("chmod 777 /tmp")).toBe(true));
|
|
15
|
+
it("matches mkfs /dev/sda", () => expect(matches("mkfs /dev/sda")).toBe(true));
|
|
16
|
+
it("matches dd if=/dev/zero", () => expect(matches("dd if=/dev/zero")).toBe(true));
|
|
17
|
+
it("does not match ls -la", () => expect(matches("ls -la")).toBe(false));
|
|
18
|
+
it("does not match echo hello", () => expect(matches("echo hello")).toBe(false));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("PROTECTED_PATHS", () => {
|
|
22
|
+
it("contains all expected paths", () => {
|
|
23
|
+
expect(PROTECTED_PATHS).toEqual([".env", ".git/", "node_modules/", ".pi/", "id_rsa", ".ssh/"]);
|
|
24
|
+
});
|
|
25
|
+
it("has length 6", () => expect(PROTECTED_PATHS).toHaveLength(6));
|
|
26
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
7
7
|
|
|
8
|
-
const DANGEROUS_PATTERNS = [
|
|
8
|
+
export const DANGEROUS_PATTERNS = [
|
|
9
9
|
/\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|.*-rf\b|.*--force\b)/,
|
|
10
10
|
/\bsudo\s+rm\b/,
|
|
11
11
|
/\b(DROP|TRUNCATE|DELETE\s+FROM)\b/i,
|
|
@@ -15,7 +15,7 @@ const DANGEROUS_PATTERNS = [
|
|
|
15
15
|
/>\s*\/dev\/sd[a-z]/,
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
-
const PROTECTED_PATHS = [".env", ".git/", "node_modules/", ".pi/", "id_rsa", ".ssh/"];
|
|
18
|
+
export const PROTECTED_PATHS = [".env", ".git/", "node_modules/", ".pi/", "id_rsa", ".ssh/"];
|
|
19
19
|
|
|
20
20
|
export default function (pi: ExtensionAPI) {
|
|
21
21
|
pi.on("tool_call", async (event, ctx) => {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { truncateText, compactContent } from "./smart-compact";
|
|
3
|
+
|
|
4
|
+
const longMultiline = (lines: number, lineLen = 100) =>
|
|
5
|
+
Array.from({ length: lines }, (_, i) => "x".repeat(lineLen) + i).join("\n");
|
|
6
|
+
|
|
7
|
+
describe("truncateText", () => {
|
|
8
|
+
it("short text returns unchanged", () => {
|
|
9
|
+
expect(truncateText("hello", 8000)).toBe("hello");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("few lines but long chars returns unchanged", () => {
|
|
13
|
+
const text = "x".repeat(9000) + "\n" + "y".repeat(9000);
|
|
14
|
+
expect(truncateText(text, 8000)).toBe(text);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("long text with many lines gets truncated", () => {
|
|
18
|
+
const text = longMultiline(200);
|
|
19
|
+
const result = truncateText(text, 8000);
|
|
20
|
+
expect(result).toContain("[...truncated");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("preserves head and tail", () => {
|
|
24
|
+
const text = longMultiline(200);
|
|
25
|
+
const result = truncateText(text, 8000);
|
|
26
|
+
expect(result.startsWith(text.slice(0, 1500))).toBe(true);
|
|
27
|
+
expect(result.endsWith(text.slice(-2500))).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("custom head/tail params work", () => {
|
|
31
|
+
const text = longMultiline(200);
|
|
32
|
+
const result = truncateText(text, 100, 50, 50);
|
|
33
|
+
expect(result).toContain("[...truncated");
|
|
34
|
+
expect(result.startsWith(text.slice(0, 50))).toBe(true);
|
|
35
|
+
expect(result.endsWith(text.slice(-50))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("compactContent", () => {
|
|
40
|
+
it("short string returns unchanged", () => {
|
|
41
|
+
expect(compactContent("short")).toBe("short");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("long string gets truncated", () => {
|
|
45
|
+
const text = longMultiline(200);
|
|
46
|
+
expect(compactContent(text)).toContain("[...truncated");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("array text block gets truncated", () => {
|
|
50
|
+
const text = longMultiline(200);
|
|
51
|
+
const result = compactContent([{ type: "text", text }]);
|
|
52
|
+
expect(result[0].text).toContain("[...truncated");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("array non-text block returned unchanged", () => {
|
|
56
|
+
const block = { type: "image", url: "x" };
|
|
57
|
+
expect(compactContent([block])).toEqual([block]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("non-array non-string returned as-is", () => {
|
|
61
|
+
expect(compactContent(42)).toBe(42);
|
|
62
|
+
expect(compactContent({ a: 1 })).toEqual({ a: 1 });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -12,7 +12,7 @@ const MAX_USER_BLOCK_CHARS = 12000;
|
|
|
12
12
|
const KEEP_HEAD = 1500;
|
|
13
13
|
const KEEP_TAIL = 2500;
|
|
14
14
|
|
|
15
|
-
function truncateText(text: string, max: number, head = KEEP_HEAD, tail = KEEP_TAIL): string {
|
|
15
|
+
export function truncateText(text: string, max: number, head = KEEP_HEAD, tail = KEEP_TAIL): string {
|
|
16
16
|
if (text.length <= max) return text;
|
|
17
17
|
const lines = text.split("\n");
|
|
18
18
|
if (lines.length <= 10) return text; // 短文本不裁
|
|
@@ -22,7 +22,7 @@ function truncateText(text: string, max: number, head = KEEP_HEAD, tail = KEEP_T
|
|
|
22
22
|
return `${headText}\n\n[...truncated ${removedLines} lines...]\n\n${tailText}`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function compactContent(content: any): any {
|
|
25
|
+
export function compactContent(content: any): any {
|
|
26
26
|
if (typeof content === "string") {
|
|
27
27
|
return truncateText(content, MAX_TOOL_OUTPUT_CHARS);
|
|
28
28
|
}
|