oh-pi 0.1.72 → 0.1.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.72",
3
+ "version": "0.1.73",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,130 @@
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
+ });
@@ -54,4 +54,46 @@ describe("buildPrompt", () => {
54
54
  const r = buildPrompt(mkTask({ description: "修复登录问题" }), "", "System");
55
55
  expect(r).toContain("Chinese");
56
56
  });
57
+
58
+ // Bio 3: 串联觅食 — tandem context
59
+ it("includes tandem parent result when provided", () => {
60
+ const r = buildPrompt(mkTask(), "", "System", 10, { parentResult: "Parent found auth module at src/auth.ts" });
61
+ expect(r).toContain("Tandem Context");
62
+ expect(r).toContain("Parent found auth module");
63
+ });
64
+
65
+ it("includes tandem prior error when provided", () => {
66
+ const r = buildPrompt(mkTask(), "", "System", 10, { priorError: "TypeError: cannot read property" });
67
+ expect(r).toContain("Prior Attempt Failed");
68
+ expect(r).toContain("TypeError: cannot read property");
69
+ });
70
+
71
+ it("includes both tandem fields when provided", () => {
72
+ const r = buildPrompt(mkTask(), "", "System", 10, {
73
+ parentResult: "Scout found files",
74
+ priorError: "Build failed",
75
+ });
76
+ expect(r).toContain("Tandem Context");
77
+ expect(r).toContain("Prior Attempt Failed");
78
+ });
79
+
80
+ it("omits tandem sections when not provided", () => {
81
+ const r = buildPrompt(mkTask(), "", "System", 10);
82
+ expect(r).not.toContain("Tandem Context");
83
+ expect(r).not.toContain("Prior Attempt Failed");
84
+ });
85
+
86
+ it("omits tandem sections when empty object", () => {
87
+ const r = buildPrompt(mkTask(), "", "System", 10, {});
88
+ expect(r).not.toContain("Tandem Context");
89
+ expect(r).not.toContain("Prior Attempt Failed");
90
+ });
91
+
92
+ it("truncates long parent result", () => {
93
+ const longResult = "x".repeat(5000);
94
+ const r = buildPrompt(mkTask(), "", "System", 10, { parentResult: longResult });
95
+ expect(r).toContain("Tandem Context");
96
+ // Should be truncated to 3000 chars
97
+ expect(r.length).toBeLessThan(longResult.length);
98
+ });
57
99
  });
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+
6
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
7
+ AuthStorage: class {},
8
+ createAgentSession: vi.fn(),
9
+ createReadTool: vi.fn(), createBashTool: vi.fn(), createEditTool: vi.fn(),
10
+ createWriteTool: vi.fn(), createGrepTool: vi.fn(), createFindTool: vi.fn(),
11
+ createLsTool: vi.fn(), ModelRegistry: class {}, SessionManager: { inMemory: vi.fn() },
12
+ SettingsManager: { inMemory: vi.fn() }, createExtensionRuntime: vi.fn(),
13
+ }));
14
+ vi.mock("@mariozechner/pi-ai", () => ({ getModel: vi.fn() }));
15
+
16
+ import { classifyError, quorumMergeTasks } from "./queen.js";
17
+ import { Nest } from "./nest.js";
18
+ import type { ColonyState, Task } from "./types.js";
19
+
20
+ // ═══ classifyError ═══
21
+
22
+ describe("classifyError", () => {
23
+ it("classifies TypeError", () => {
24
+ expect(classifyError("TypeError: cannot read property 'x'")).toBe("type_error");
25
+ });
26
+
27
+ it("classifies TS errors", () => {
28
+ expect(classifyError("TS2345: Argument of type")).toBe("type_error");
29
+ });
30
+
31
+ it("classifies permission errors", () => {
32
+ expect(classifyError("EACCES: permission denied")).toBe("permission");
33
+ });
34
+
35
+ it("classifies 401", () => {
36
+ expect(classifyError("Error: 401 Unauthorized")).toBe("permission");
37
+ });
38
+
39
+ it("classifies timeout", () => {
40
+ expect(classifyError("Error: Timeout after 5000ms")).toBe("timeout");
41
+ });
42
+
43
+ it("classifies ETIMEDOUT", () => {
44
+ expect(classifyError("connect ETIMEDOUT 1.2.3.4")).toBe("timeout");
45
+ });
46
+
47
+ it("classifies ENOENT", () => {
48
+ expect(classifyError("ENOENT: no such file or directory")).toBe("not_found");
49
+ });
50
+
51
+ it("classifies Cannot find module", () => {
52
+ expect(classifyError("Cannot find module './foo'")).toBe("not_found");
53
+ });
54
+
55
+ it("classifies syntax errors", () => {
56
+ expect(classifyError("SyntaxError: Unexpected token")).toBe("syntax");
57
+ });
58
+
59
+ it("classifies rate limit", () => {
60
+ expect(classifyError("Error: 429 Too Many Requests")).toBe("rate_limit");
61
+ });
62
+
63
+ it("returns unknown for unrecognized errors", () => {
64
+ expect(classifyError("Something completely different")).toBe("unknown");
65
+ });
66
+
67
+ it("handles empty string", () => {
68
+ expect(classifyError("")).toBe("unknown");
69
+ });
70
+ });
71
+
72
+ // ═══ quorumMergeTasks ═══
73
+
74
+ const mkState = (overrides: Partial<ColonyState> = {}): ColonyState => ({
75
+ id: "test-colony", goal: "test", status: "working",
76
+ tasks: [], ants: [], pheromones: [],
77
+ concurrency: { current: 2, min: 1, max: 4, optimal: 3, history: [] },
78
+ metrics: { tasksTotal: 0, tasksDone: 0, tasksFailed: 0, antsSpawned: 0, totalCost: 0, totalTokens: 0, startTime: Date.now(), throughputHistory: [] },
79
+ maxCost: null, modelOverrides: {}, createdAt: Date.now(), finishedAt: null,
80
+ ...overrides,
81
+ });
82
+
83
+ const mkTask = (overrides: Partial<Task> = {}): Task => ({
84
+ id: `t-${Math.random().toString(36).slice(2)}`, parentId: null,
85
+ title: "Test task", description: "Do something",
86
+ caste: "worker", status: "pending", priority: 3, files: [],
87
+ claimedBy: null, result: null, error: null, spawnedTasks: [],
88
+ createdAt: Date.now(), startedAt: null, finishedAt: null,
89
+ ...overrides,
90
+ });
91
+
92
+ let tmpDir: string;
93
+ let nest: Nest;
94
+
95
+ beforeEach(() => {
96
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "queen-test-"));
97
+ nest = new Nest(tmpDir, "test-colony");
98
+ nest.init(mkState());
99
+ });
100
+
101
+ afterEach(() => {
102
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
103
+ });
104
+
105
+ describe("quorumMergeTasks", () => {
106
+ it("does nothing with 0-1 tasks", () => {
107
+ const t1 = mkTask({ files: ["a.ts"], priority: 3 });
108
+ nest.writeTask(t1);
109
+ quorumMergeTasks(nest);
110
+ const tasks = nest.getAllTasks().filter(t => t.status === "pending");
111
+ expect(tasks).toHaveLength(1);
112
+ });
113
+
114
+ it("merges duplicate tasks with same files", () => {
115
+ const t1 = mkTask({ id: "t-1", title: "Fix auth", files: ["a.ts", "b.ts"], priority: 3 });
116
+ const t2 = mkTask({ id: "t-2", title: "Fix auth v2", files: ["a.ts", "b.ts"], priority: 3 });
117
+ nest.writeTask(t1);
118
+ nest.writeTask(t2);
119
+ quorumMergeTasks(nest);
120
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
121
+ const done = nest.getAllTasks().filter(t => t.status === "done");
122
+ expect(pending).toHaveLength(1);
123
+ expect(done).toHaveLength(1);
124
+ expect(done[0].result).toContain("quorum");
125
+ });
126
+
127
+ it("boosts priority of merged task", () => {
128
+ const t1 = mkTask({ id: "t-1", files: ["x.ts"], priority: 3 });
129
+ const t2 = mkTask({ id: "t-2", files: ["x.ts"], priority: 3 });
130
+ nest.writeTask(t1);
131
+ nest.writeTask(t2);
132
+ quorumMergeTasks(nest);
133
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
134
+ expect(pending[0].priority).toBe(2); // boosted from 3 to 2
135
+ });
136
+
137
+ it("does not merge tasks with different files", () => {
138
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"] });
139
+ const t2 = mkTask({ id: "t-2", files: ["b.ts"] });
140
+ nest.writeTask(t1);
141
+ nest.writeTask(t2);
142
+ quorumMergeTasks(nest);
143
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
144
+ expect(pending).toHaveLength(2);
145
+ });
146
+
147
+ it("merges context from duplicate tasks", () => {
148
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"], context: "context A" });
149
+ const t2 = mkTask({ id: "t-2", files: ["a.ts"], context: "context B" });
150
+ nest.writeTask(t1);
151
+ nest.writeTask(t2);
152
+ quorumMergeTasks(nest);
153
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
154
+ expect(pending[0].context).toContain("context A");
155
+ expect(pending[0].context).toContain("context B");
156
+ });
157
+
158
+ it("handles three duplicates", () => {
159
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"], priority: 4 });
160
+ const t2 = mkTask({ id: "t-2", files: ["a.ts"], priority: 4 });
161
+ const t3 = mkTask({ id: "t-3", files: ["a.ts"], priority: 4 });
162
+ nest.writeTask(t1);
163
+ nest.writeTask(t2);
164
+ nest.writeTask(t3);
165
+ quorumMergeTasks(nest);
166
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
167
+ const done = nest.getAllTasks().filter(t => t.status === "done");
168
+ expect(pending).toHaveLength(1);
169
+ expect(done).toHaveLength(2);
170
+ expect(pending[0].priority).toBe(3); // boosted from 4 to 3
171
+ });
172
+
173
+ it("skips non-pending tasks", () => {
174
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"], status: "done" });
175
+ const t2 = mkTask({ id: "t-2", files: ["a.ts"], status: "pending" });
176
+ nest.writeTask(t1);
177
+ nest.writeTask(t2);
178
+ quorumMergeTasks(nest);
179
+ // Only 1 pending, so no merge
180
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
181
+ expect(pending).toHaveLength(1);
182
+ });
183
+
184
+ it("does not boost priority below 1", () => {
185
+ const t1 = mkTask({ id: "t-1", files: ["a.ts"], priority: 1 });
186
+ const t2 = mkTask({ id: "t-2", files: ["a.ts"], priority: 1 });
187
+ nest.writeTask(t1);
188
+ nest.writeTask(t2);
189
+ quorumMergeTasks(nest);
190
+ const pending = nest.getAllTasks().filter(t => t.status === "pending");
191
+ expect(pending[0].priority).toBe(1); // stays at 1
192
+ });
193
+ });
@@ -102,7 +102,7 @@ function childTaskFromParsed(
102
102
  * Bio 5: 蚁群投票 — 合并多 Scout 产生的重复任务
103
103
  * 相同文件集合的任务合并,被多 Scout 提及的任务 priority 提升
104
104
  */
105
- function quorumMergeTasks(nest: Nest): void {
105
+ export function quorumMergeTasks(nest: Nest): void {
106
106
  const tasks = nest.getAllTasks().filter(t =>
107
107
  (t.caste === "worker" || t.caste === "drone") && t.status === "pending"
108
108
  );
@@ -198,7 +198,7 @@ interface WaveOptions {
198
198
  /**
199
199
  * Bio 6: 尸体清理 — 错误模式分类
200
200
  */
201
- function classifyError(errStr: string): string {
201
+ export function classifyError(errStr: string): string {
202
202
  if (errStr.includes("TypeError") || errStr.includes("type") || errStr.includes("TS")) return "type_error";
203
203
  if (errStr.includes("permission") || errStr.includes("401") || errStr.includes("EACCES")) return "permission";
204
204
  if (errStr.includes("timeout") || errStr.includes("Timeout") || errStr.includes("ETIMEDOUT")) return "timeout";