oh-pi 0.1.76 → 0.1.78
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/README.md +1 -1
- package/README.zh.md +1 -1
- package/dist/index.js +1 -1
- package/dist/registry.js +0 -1
- package/dist/utils/writers.js +12 -0
- package/dist/utils/writers.test.d.ts +1 -0
- package/dist/utils/writers.test.js +60 -0
- package/package.json +4 -2
- package/pi-package/agents/colony-operator.md +3 -2
- package/pi-package/extensions/ant-colony/index.ts +73 -3
- package/pi-package/extensions/ant-colony/concurrency.test.ts +0 -70
- package/pi-package/extensions/ant-colony/deps.test.ts +0 -62
- package/pi-package/extensions/ant-colony/nest.test.ts +0 -130
- package/pi-package/extensions/ant-colony/parser.test.ts +0 -143
- package/pi-package/extensions/ant-colony/prompts.test.ts +0 -99
- package/pi-package/extensions/ant-colony/queen.test.ts +0 -227
- package/pi-package/extensions/ant-colony/spawner.test.ts +0 -44
- package/pi-package/extensions/ant-colony/types.test.ts +0 -36
- package/pi-package/extensions/ant-colony/ui.test.ts +0 -84
- package/pi-package/extensions/auto-update.test.ts +0 -15
- package/pi-package/extensions/safe-guard.test.ts +0 -26
- package/pi-package/extensions/smart-compact.test.ts +0 -64
- package/pi-package/extensions/smart-compact.ts +0 -89
package/README.md
CHANGED
|
@@ -157,7 +157,7 @@ Use `/colony-stop` to abort a running colony.
|
|
|
157
157
|
|
|
158
158
|
### Signal Protocol
|
|
159
159
|
|
|
160
|
-
The colony communicates with the main conversation via structured signals, so the model never has to guess background state:
|
|
160
|
+
The colony communicates with the main conversation via structured signals, so the model never has to guess background state. Updates are passively pushed (non-blocking), so polling is optional:
|
|
161
161
|
|
|
162
162
|
| Signal | Meaning |
|
|
163
163
|
|--------|---------|
|
package/README.zh.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -43,7 +43,7 @@ async function quickFlow(env) {
|
|
|
43
43
|
providers,
|
|
44
44
|
theme: "dark",
|
|
45
45
|
keybindings: "default",
|
|
46
|
-
extensions: ["safe-guard", "git-guard", "auto-session-name", "custom-footer", "compact-header", "auto-update"
|
|
46
|
+
extensions: ["safe-guard", "git-guard", "auto-session-name", "custom-footer", "compact-header", "auto-update"],
|
|
47
47
|
prompts: ["review", "fix", "explain", "commit", "test"],
|
|
48
48
|
agents: "general-developer",
|
|
49
49
|
thinking: "medium",
|
package/dist/registry.js
CHANGED
|
@@ -49,7 +49,6 @@ export const EXTENSIONS = [
|
|
|
49
49
|
{ name: "compact-header", label: "⚡ Compact Header — Dense startup info replacing verbose output", default: true },
|
|
50
50
|
{ name: "ant-colony", label: "🐜 Ant Colony — Autonomous multi-agent swarm with adaptive concurrency", default: false },
|
|
51
51
|
{ name: "auto-update", label: "🔄 Auto Update — Check for oh-pi updates on startup and notify", default: true },
|
|
52
|
-
{ name: "smart-compact", label: "🗜️ Smart Compact — Trim large tool outputs and old messages in-flight", default: true },
|
|
53
52
|
{ name: "bg-process", label: "⏳ Bg Process — Auto-background long-running commands (dev servers, etc.)", default: false },
|
|
54
53
|
];
|
|
55
54
|
/** 快捷键绑定方案(default / vim / emacs) */
|
package/dist/utils/writers.js
CHANGED
|
@@ -3,6 +3,15 @@ import { join } from "node:path";
|
|
|
3
3
|
import { KEYBINDING_SCHEMES, MODEL_CAPABILITIES, PROVIDERS } from "../registry.js";
|
|
4
4
|
import { resources } from "./resources.js";
|
|
5
5
|
import { ensureDir, syncDir } from "./install.js";
|
|
6
|
+
const ANT_COLONY_AUTOTRIGGER_GUIDE = `## Ant Colony Auto-Trigger
|
|
7
|
+
If the ant_colony tool is available, automatically use it when the task is complex:
|
|
8
|
+
- 3 or more files likely need changes
|
|
9
|
+
- 2 or more independent workstreams exist
|
|
10
|
+
- large refactor / migration / feature implementation
|
|
11
|
+
|
|
12
|
+
For simple one-file tasks, execute directly without colony overhead.
|
|
13
|
+
After launching ant_colony, use passive mode: wait for COLONY_SIGNAL updates and do not poll bg_colony_status unless the user explicitly asks for a manual snapshot.
|
|
14
|
+
`;
|
|
6
15
|
/** 步骤 1-2: 生成 auth.json + settings.json */
|
|
7
16
|
export function writeProviderEnv(agentDir, config) {
|
|
8
17
|
// auth.json
|
|
@@ -103,6 +112,9 @@ export function writeAgents(agentDir, config) {
|
|
|
103
112
|
const lang = langNames[config.locale] ?? config.locale;
|
|
104
113
|
content = `## Language\nAlways respond in ${lang}. Use the user's language for all conversations and explanations. Code, commands, and technical terms can remain in English.\n\n${content}`;
|
|
105
114
|
}
|
|
115
|
+
if (config.extensions.includes("ant-colony") && config.agents !== "colony-operator") {
|
|
116
|
+
content = `${content.trimEnd()}\n\n${ANT_COLONY_AUTOTRIGGER_GUIDE}`;
|
|
117
|
+
}
|
|
106
118
|
writeFileSync(join(agentDir, "AGENTS.md"), content);
|
|
107
119
|
}
|
|
108
120
|
catch { /* template not found, skip */ }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { writeAgents } from "./writers.js";
|
|
6
|
+
const tempDirs = [];
|
|
7
|
+
function makeTempDir() {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), "oh-pi-writers-"));
|
|
9
|
+
tempDirs.push(dir);
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
function makeConfig(overrides) {
|
|
13
|
+
return {
|
|
14
|
+
providers: [],
|
|
15
|
+
theme: "dark",
|
|
16
|
+
keybindings: "default",
|
|
17
|
+
extensions: [],
|
|
18
|
+
prompts: [],
|
|
19
|
+
agents: "general-developer",
|
|
20
|
+
thinking: "medium",
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const dir of tempDirs.splice(0)) {
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
describe("writeAgents", () => {
|
|
30
|
+
it("appends ant-colony auto-trigger guidance for non-colony operator agents", () => {
|
|
31
|
+
const dir = makeTempDir();
|
|
32
|
+
writeAgents(dir, makeConfig({
|
|
33
|
+
agents: "general-developer",
|
|
34
|
+
extensions: ["ant-colony"],
|
|
35
|
+
}));
|
|
36
|
+
const content = readFileSync(join(dir, "AGENTS.md"), "utf8");
|
|
37
|
+
expect(content).toContain("## Ant Colony Auto-Trigger");
|
|
38
|
+
expect(content).toContain("automatically use it when the task is complex");
|
|
39
|
+
expect(content).toContain("COLONY_SIGNAL");
|
|
40
|
+
});
|
|
41
|
+
it("does not append guidance when ant-colony extension is disabled", () => {
|
|
42
|
+
const dir = makeTempDir();
|
|
43
|
+
writeAgents(dir, makeConfig({
|
|
44
|
+
agents: "general-developer",
|
|
45
|
+
extensions: [],
|
|
46
|
+
}));
|
|
47
|
+
const content = readFileSync(join(dir, "AGENTS.md"), "utf8");
|
|
48
|
+
expect(content).not.toContain("## Ant Colony Auto-Trigger");
|
|
49
|
+
});
|
|
50
|
+
it("does not append duplicate guidance for colony-operator template", () => {
|
|
51
|
+
const dir = makeTempDir();
|
|
52
|
+
writeAgents(dir, makeConfig({
|
|
53
|
+
agents: "colony-operator",
|
|
54
|
+
extensions: ["ant-colony"],
|
|
55
|
+
}));
|
|
56
|
+
const content = readFileSync(join(dir, "AGENTS.md"), "utf8");
|
|
57
|
+
expect(content).not.toContain("## Ant Colony Auto-Trigger");
|
|
58
|
+
expect(content).toContain("You command an autonomous ant colony");
|
|
59
|
+
});
|
|
60
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.78",
|
|
4
4
|
"description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
12
|
"pi-package",
|
|
13
|
-
"README.md"
|
|
13
|
+
"README.md",
|
|
14
|
+
"!**/*.test.ts",
|
|
15
|
+
"!**/*.spec.ts"
|
|
14
16
|
],
|
|
15
17
|
"scripts": {
|
|
16
18
|
"build": "tsc",
|
|
@@ -17,8 +17,9 @@ You command an autonomous ant colony. Complex tasks are delegated to the swarm,
|
|
|
17
17
|
## Workflow
|
|
18
18
|
1. Assess task scope
|
|
19
19
|
2. If colony-worthy → use `ant_colony` tool with clear goal
|
|
20
|
-
3.
|
|
21
|
-
4.
|
|
20
|
+
3. After launch, use passive mode: wait for `COLONY_SIGNAL:*` updates; do not poll `bg_colony_status` unless user explicitly asks
|
|
21
|
+
4. If simple → do it directly
|
|
22
|
+
5. Review colony output, fix gaps manually if needed
|
|
22
23
|
|
|
23
24
|
## Code Standards
|
|
24
25
|
- Follow existing conventions
|
|
@@ -59,6 +59,44 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
59
59
|
// 当前运行中的后台蚁群(同时只允许一个)
|
|
60
60
|
let activeColony: BackgroundColony | null = null;
|
|
61
61
|
|
|
62
|
+
// 防止主进程主动轮询导致阻塞:仅允许显式请求的手动快照,并加冷却
|
|
63
|
+
let lastBgStatusSnapshotAt = 0;
|
|
64
|
+
const STATUS_SNAPSHOT_COOLDOWN_MS = 15_000;
|
|
65
|
+
|
|
66
|
+
const extractMessageText = (message: any): string => {
|
|
67
|
+
const c = message?.content;
|
|
68
|
+
if (typeof c === "string") return c;
|
|
69
|
+
if (Array.isArray(c)) {
|
|
70
|
+
return c.map((p: any) => {
|
|
71
|
+
if (typeof p === "string") return p;
|
|
72
|
+
if (typeof p?.text === "string") return p.text;
|
|
73
|
+
if (typeof p?.content === "string") return p.content;
|
|
74
|
+
return "";
|
|
75
|
+
}).join("\n");
|
|
76
|
+
}
|
|
77
|
+
return "";
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const lastUserMessageText = (ctx: any): string => {
|
|
81
|
+
try {
|
|
82
|
+
const branch = ctx?.sessionManager?.getBranch?.() ?? [];
|
|
83
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
84
|
+
const e = branch[i];
|
|
85
|
+
if (e?.type === "message" && e.message?.role === "user") {
|
|
86
|
+
return extractMessageText(e.message).trim();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// ignore
|
|
91
|
+
}
|
|
92
|
+
return "";
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isExplicitStatusRequest = (ctx: any): boolean => {
|
|
96
|
+
const text = lastUserMessageText(ctx);
|
|
97
|
+
return /(?:\/colony-status|bg_colony_status)|(?:(?:蚁群|colony).{0,20}(?:状态|进度|进展|汇报|快照|status|progress|snapshot|update|check))|(?:(?:状态|进度|进展|汇报|快照|status|progress|snapshot|update|check).{0,20}(?:蚁群|colony))/i.test(text);
|
|
98
|
+
};
|
|
99
|
+
|
|
62
100
|
const calcProgress = (m?: ColonyMetrics | null) => {
|
|
63
101
|
if (!m || m.tasksTotal <= 0) return 0;
|
|
64
102
|
return Math.max(0, Math.min(1, m.tasksDone / m.tasksTotal));
|
|
@@ -280,6 +318,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
280
318
|
colony.promise = resume ? resumeColony(colonyOpts) : runColony(colonyOpts);
|
|
281
319
|
|
|
282
320
|
activeColony = colony;
|
|
321
|
+
lastBgStatusSnapshotAt = 0;
|
|
283
322
|
throttledRender();
|
|
284
323
|
|
|
285
324
|
// 后台等待完成,注入结果
|
|
@@ -627,7 +666,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
627
666
|
launchBackgroundColony(colonyParams);
|
|
628
667
|
|
|
629
668
|
return {
|
|
630
|
-
content: [{ type: "text", text: `[COLONY_SIGNAL:LAUNCHED]\n🐜 Colony launched in background.\nGoal: ${params.goal}\n\nThe colony is
|
|
669
|
+
content: [{ type: "text", text: `[COLONY_SIGNAL:LAUNCHED]\n🐜 Colony launched in background.\nGoal: ${params.goal}\n\nThe colony runs autonomously in passive mode. Progress is pushed via [COLONY_SIGNAL:*] follow-up messages. Do not poll bg_colony_status unless the user explicitly asks for a manual snapshot.` }],
|
|
631
670
|
};
|
|
632
671
|
},
|
|
633
672
|
|
|
@@ -689,9 +728,40 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
689
728
|
pi.registerTool({
|
|
690
729
|
name: "bg_colony_status",
|
|
691
730
|
label: "Colony Status",
|
|
692
|
-
description: "
|
|
731
|
+
description: "Optional manual snapshot for a running colony. Progress is pushed passively via COLONY_SIGNAL follow-up messages; call this only when the user explicitly asks.",
|
|
693
732
|
parameters: Type.Object({}),
|
|
694
|
-
async execute() {
|
|
733
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
734
|
+
if (!activeColony) {
|
|
735
|
+
return {
|
|
736
|
+
content: [{ type: "text" as const, text: "No colony is currently running." }],
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const explicit = isExplicitStatusRequest(ctx);
|
|
741
|
+
if (!explicit) {
|
|
742
|
+
return {
|
|
743
|
+
content: [{
|
|
744
|
+
type: "text" as const,
|
|
745
|
+
text: "Passive mode is active. Colony progress is already pushed via [COLONY_SIGNAL:*] follow-up messages. Skipping bg_colony_status polling to avoid blocking the main process. Ask explicitly for a manual snapshot if needed.",
|
|
746
|
+
}],
|
|
747
|
+
isError: true,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const now = Date.now();
|
|
752
|
+
const delta = now - lastBgStatusSnapshotAt;
|
|
753
|
+
if (delta < STATUS_SNAPSHOT_COOLDOWN_MS) {
|
|
754
|
+
const waitSec = Math.ceil((STATUS_SNAPSHOT_COOLDOWN_MS - delta) / 1000);
|
|
755
|
+
return {
|
|
756
|
+
content: [{
|
|
757
|
+
type: "text" as const,
|
|
758
|
+
text: `Manual status snapshot is rate-limited. Please wait ${waitSec}s to avoid active polling loops.`,
|
|
759
|
+
}],
|
|
760
|
+
isError: true,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
lastBgStatusSnapshotAt = now;
|
|
695
765
|
return {
|
|
696
766
|
content: [{ type: "text" as const, text: buildStatusText() }],
|
|
697
767
|
};
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import * as os from "node:os";
|
|
5
|
-
import { Nest } from "./nest.js";
|
|
6
|
-
import type { ColonyState, Pheromone } from "./types.js";
|
|
7
|
-
|
|
8
|
-
const mkState = (overrides: Partial<ColonyState> = {}): ColonyState => ({
|
|
9
|
-
id: "test-colony", goal: "test", status: "working",
|
|
10
|
-
tasks: [], ants: [], pheromones: [],
|
|
11
|
-
concurrency: { current: 2, min: 1, max: 4, optimal: 3, history: [] },
|
|
12
|
-
metrics: { tasksTotal: 0, tasksDone: 0, tasksFailed: 0, antsSpawned: 0, totalCost: 0, totalTokens: 0, startTime: Date.now(), throughputHistory: [] },
|
|
13
|
-
maxCost: null, modelOverrides: {}, createdAt: Date.now(), finishedAt: null,
|
|
14
|
-
...overrides,
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
const mkPheromone = (overrides: Partial<Pheromone> = {}): Pheromone => ({
|
|
18
|
-
id: `p-${Math.random().toString(36).slice(2)}`, type: "warning", antId: "ant-1", antCaste: "worker",
|
|
19
|
-
taskId: "t-1", content: "test", files: ["a.ts"], strength: 1.0, createdAt: Date.now(),
|
|
20
|
-
...overrides,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
let tmpDir: string;
|
|
24
|
-
let nest: Nest;
|
|
25
|
-
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nest-test-"));
|
|
28
|
-
nest = new Nest(tmpDir, "test-colony");
|
|
29
|
-
nest.init(mkState());
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("getStateLight", () => {
|
|
37
|
-
it("returns state without triggering pheromone read", () => {
|
|
38
|
-
nest.dropPheromone(mkPheromone());
|
|
39
|
-
const light = nest.getStateLight();
|
|
40
|
-
expect(light.id).toBe("test-colony");
|
|
41
|
-
expect(light.tasks).toEqual([]);
|
|
42
|
-
// pheromones should not be populated by getStateLight
|
|
43
|
-
// (it returns stateCache which has empty pheromones from init)
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("includes tasks from cache", () => {
|
|
47
|
-
nest.writeTask({
|
|
48
|
-
id: "t-1", parentId: null, title: "Test", description: "desc",
|
|
49
|
-
caste: "worker", status: "pending", priority: 3, files: [],
|
|
50
|
-
claimedBy: null, result: null, error: null, spawnedTasks: [],
|
|
51
|
-
createdAt: Date.now(), startedAt: null, finishedAt: null,
|
|
52
|
-
});
|
|
53
|
-
const light = nest.getStateLight();
|
|
54
|
-
expect(light.tasks).toHaveLength(1);
|
|
55
|
-
expect(light.tasks[0].id).toBe("t-1");
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("countWarnings", () => {
|
|
60
|
-
it("returns 0 when no pheromones", () => {
|
|
61
|
-
expect(nest.countWarnings(["a.ts"])).toBe(0);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("counts warning pheromones for matching files", () => {
|
|
65
|
-
nest.dropPheromone(mkPheromone({ type: "warning", files: ["a.ts"] }));
|
|
66
|
-
nest.dropPheromone(mkPheromone({ type: "warning", files: ["a.ts"] }));
|
|
67
|
-
nest.dropPheromone(mkPheromone({ type: "completion", files: ["a.ts"] }));
|
|
68
|
-
expect(nest.countWarnings(["a.ts"])).toBe(2);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("counts repellent pheromones", () => {
|
|
72
|
-
nest.dropPheromone(mkPheromone({ type: "repellent", files: ["b.ts"] }));
|
|
73
|
-
expect(nest.countWarnings(["b.ts"])).toBe(1);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("returns 0 for unrelated files", () => {
|
|
77
|
-
nest.dropPheromone(mkPheromone({ type: "warning", files: ["a.ts"] }));
|
|
78
|
-
expect(nest.countWarnings(["c.ts"])).toBe(0);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe("pheromone dirty flag", () => {
|
|
83
|
-
it("rebuilds index after dropPheromone", () => {
|
|
84
|
-
nest.dropPheromone(mkPheromone({ type: "discovery", files: ["x.ts"] }));
|
|
85
|
-
const pheromones = nest.getAllPheromones();
|
|
86
|
-
expect(pheromones.length).toBe(1);
|
|
87
|
-
expect(pheromones[0].type).toBe("discovery");
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("does not rebuild index when nothing changed", () => {
|
|
91
|
-
nest.dropPheromone(mkPheromone({ files: ["x.ts"] }));
|
|
92
|
-
nest.getAllPheromones(); // builds index, clears dirty
|
|
93
|
-
// Second call should use cached index (no new data, no GC)
|
|
94
|
-
const p2 = nest.getAllPheromones();
|
|
95
|
-
expect(p2.length).toBe(1);
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe("claimNextTask", () => {
|
|
100
|
-
it("claims highest scored pending task", () => {
|
|
101
|
-
nest.writeTask({
|
|
102
|
-
id: "t-low", parentId: null, title: "Low", description: "",
|
|
103
|
-
caste: "worker", status: "pending", priority: 5, files: [],
|
|
104
|
-
claimedBy: null, result: null, error: null, spawnedTasks: [],
|
|
105
|
-
createdAt: Date.now(), startedAt: null, finishedAt: null,
|
|
106
|
-
});
|
|
107
|
-
nest.writeTask({
|
|
108
|
-
id: "t-high", parentId: null, title: "High", description: "",
|
|
109
|
-
caste: "worker", status: "pending", priority: 1, files: [],
|
|
110
|
-
claimedBy: null, result: null, error: null, spawnedTasks: [],
|
|
111
|
-
createdAt: Date.now(), startedAt: null, finishedAt: null,
|
|
112
|
-
});
|
|
113
|
-
const claimed = nest.claimNextTask("worker", "ant-1");
|
|
114
|
-
expect(claimed).not.toBeNull();
|
|
115
|
-
expect(claimed!.id).toBe("t-high");
|
|
116
|
-
expect(claimed!.status).toBe("claimed");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("returns null when no pending tasks", () => {
|
|
120
|
-
expect(nest.claimNextTask("worker", "ant-1")).toBeNull();
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe("withStateLock spin", () => {
|
|
125
|
-
it("updateState works under normal conditions", () => {
|
|
126
|
-
nest.updateState({ status: "reviewing" });
|
|
127
|
-
const state = nest.getStateLight();
|
|
128
|
-
expect(state.status).toBe("reviewing");
|
|
129
|
-
});
|
|
130
|
-
});
|
|
@@ -1,143 +0,0 @@
|
|
|
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
|
-
it("parses chinese task format with full-width colon", () => {
|
|
86
|
-
const output = `### 任务:生成重启检查报告
|
|
87
|
-
- 描述:创建重启检查文档
|
|
88
|
-
- 文件:docs/ant-colony-restart-check.md
|
|
89
|
-
- 角色:worker
|
|
90
|
-
- 优先级:1`;
|
|
91
|
-
const tasks = parseSubTasks(output);
|
|
92
|
-
expect(tasks).toHaveLength(1);
|
|
93
|
-
expect(tasks[0].title).toContain("重启检查报告");
|
|
94
|
-
expect(tasks[0].files).toEqual(["docs/ant-colony-restart-check.md"]);
|
|
95
|
-
expect(tasks[0].caste).toBe("worker");
|
|
96
|
-
expect(tasks[0].priority).toBe(1);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("does not infer tasks from plain next-step narrative", () => {
|
|
100
|
-
const output = `目前发现如下\n\n下一步我会继续定位:
|
|
101
|
-
- 写入 docs/ant-colony-restart-check.md
|
|
102
|
-
- 校验 src/index.ts 的入口流程`;
|
|
103
|
-
const tasks = parseSubTasks(output);
|
|
104
|
-
expect(tasks).toEqual([]);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("parses bold markdown field keys", () => {
|
|
108
|
-
const output = `### TASK: Harden parser
|
|
109
|
-
- **description**: support bold fields
|
|
110
|
-
- **files**: pi-package/extensions/ant-colony/parser.ts
|
|
111
|
-
- **caste**: worker
|
|
112
|
-
- **priority**: 2`;
|
|
113
|
-
const tasks = parseSubTasks(output);
|
|
114
|
-
expect(tasks).toHaveLength(1);
|
|
115
|
-
expect(tasks[0].files).toEqual(["pi-package/extensions/ant-colony/parser.ts"]);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe("extractPheromones", () => {
|
|
120
|
-
it("extracts discovery section", () => {
|
|
121
|
-
const p = extractPheromones("ant-1", "scout", "t-1", "## Discoveries\n- Found auth\n\n## Other\nstuff", ["a.ts"]);
|
|
122
|
-
expect(p.some(x => x.type === "discovery")).toBe(true);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("extracts warning section", () => {
|
|
126
|
-
const p = extractPheromones("ant-1", "scout", "t-1", "## Warnings\n- Conflict\n", []);
|
|
127
|
-
expect(p.some(x => x.type === "warning")).toBe(true);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("adds repellent on failure", () => {
|
|
131
|
-
const p = extractPheromones("ant-1", "worker", "t-1", "output", ["a.ts"], true);
|
|
132
|
-
expect(p.some(x => x.type === "repellent")).toBe(true);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("returns empty for no matching sections", () => {
|
|
136
|
-
expect(extractPheromones("ant-1", "worker", "t-1", "nothing", [])).toEqual([]);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("extracts Files Changed as completion", () => {
|
|
140
|
-
const p = extractPheromones("ant-1", "worker", "t-1", "## Files Changed\n- src/foo.ts\n", ["src/foo.ts"]);
|
|
141
|
-
expect(p.some(x => x.type === "completion")).toBe(true);
|
|
142
|
-
});
|
|
143
|
-
});
|