@pi-unipi/subagents 0.1.10 → 0.1.12
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": "@pi-unipi/subagents",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
|
-
"dev": "tsc --watch"
|
|
16
|
+
"dev": "tsc --watch",
|
|
17
|
+
"test": "node --test src/__tests__/*.test.ts"
|
|
17
18
|
},
|
|
18
19
|
"dependencies": {
|
|
19
20
|
"@pi-unipi/core": "*",
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Config auto-generation and corruption recovery
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Missing config → auto-generated with defaults
|
|
6
|
+
* - Corrupted config → renamed to .json.bak, fresh generated
|
|
7
|
+
* - Workspace config overrides global config
|
|
8
|
+
* - Atomic writes prevent corruption
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, renameSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
// Inline config implementation for testing
|
|
18
|
+
const DEFAULT_CONFIG = {
|
|
19
|
+
maxConcurrent: 4,
|
|
20
|
+
enabled: true,
|
|
21
|
+
types: {
|
|
22
|
+
explore: { enabled: true },
|
|
23
|
+
work: { enabled: true },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
interface SubagentsConfig {
|
|
28
|
+
maxConcurrent: number;
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
types: Record<string, { enabled?: boolean }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadConfigFromPath(filePath: string): SubagentsConfig | null {
|
|
34
|
+
if (!existsSync(filePath)) return null;
|
|
35
|
+
try {
|
|
36
|
+
const content = readFileSync(filePath, "utf-8");
|
|
37
|
+
const parsed = JSON.parse(content);
|
|
38
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
39
|
+
return parsed as SubagentsConfig;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function repairCorrupted(filePath: string): SubagentsConfig {
|
|
46
|
+
const backupPath = filePath + ".bak";
|
|
47
|
+
try {
|
|
48
|
+
renameSync(filePath, backupPath);
|
|
49
|
+
} catch {
|
|
50
|
+
// If rename fails, just overwrite
|
|
51
|
+
}
|
|
52
|
+
writeConfigAtomic(filePath, DEFAULT_CONFIG);
|
|
53
|
+
return DEFAULT_CONFIG;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeConfigAtomic(filePath: string, config: SubagentsConfig): void {
|
|
57
|
+
const tmpPath = filePath + ".tmp";
|
|
58
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
|
|
59
|
+
renameSync(tmpPath, filePath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function initConfig(globalDir: string, workspaceDir: string): SubagentsConfig {
|
|
63
|
+
const globalPath = join(globalDir, "subagents.json");
|
|
64
|
+
const workspacePath = join(workspaceDir, "subagents.json");
|
|
65
|
+
|
|
66
|
+
// Load or create global config
|
|
67
|
+
let globalConfig = loadConfigFromPath(globalPath);
|
|
68
|
+
if (globalConfig === null) {
|
|
69
|
+
globalConfig = repairCorrupted(globalPath);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Load workspace override if exists
|
|
73
|
+
const workspaceConfig = loadConfigFromPath(workspacePath);
|
|
74
|
+
|
|
75
|
+
if (workspaceConfig) {
|
|
76
|
+
// Merge: workspace overrides global on any field present
|
|
77
|
+
return {
|
|
78
|
+
...globalConfig,
|
|
79
|
+
...workspaceConfig,
|
|
80
|
+
types: {
|
|
81
|
+
...globalConfig.types,
|
|
82
|
+
...workspaceConfig.types,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return globalConfig;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("Config Management", () => {
|
|
91
|
+
let testDir: string;
|
|
92
|
+
let globalDir: string;
|
|
93
|
+
let workspaceDir: string;
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
// Create temp directories for testing
|
|
97
|
+
testDir = join(tmpdir(), `subagents-test-${Date.now()}`);
|
|
98
|
+
globalDir = join(testDir, "global");
|
|
99
|
+
workspaceDir = join(testDir, "workspace");
|
|
100
|
+
mkdirSync(globalDir, { recursive: true });
|
|
101
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
// Cleanup
|
|
106
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("Missing config", () => {
|
|
110
|
+
it("should auto-generate with defaults when no config exists", () => {
|
|
111
|
+
const config = initConfig(globalDir, workspaceDir);
|
|
112
|
+
|
|
113
|
+
assert.deepEqual(config, DEFAULT_CONFIG);
|
|
114
|
+
assert.equal(existsSync(join(globalDir, "subagents.json")), true);
|
|
115
|
+
|
|
116
|
+
// Verify the generated file
|
|
117
|
+
const content = readFileSync(join(globalDir, "subagents.json"), "utf-8");
|
|
118
|
+
const parsed = JSON.parse(content);
|
|
119
|
+
assert.deepEqual(parsed, DEFAULT_CONFIG);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should create global config even if workspace exists", () => {
|
|
123
|
+
// Write workspace config only
|
|
124
|
+
const workspaceConfig = { maxConcurrent: 8, enabled: true, types: {} };
|
|
125
|
+
writeFileSync(join(workspaceDir, "subagents.json"), JSON.stringify(workspaceConfig));
|
|
126
|
+
|
|
127
|
+
const config = initConfig(globalDir, workspaceDir);
|
|
128
|
+
|
|
129
|
+
// Global should be created
|
|
130
|
+
assert.equal(existsSync(join(globalDir, "subagents.json")), true);
|
|
131
|
+
// Config should merge
|
|
132
|
+
assert.equal(config.maxConcurrent, 8); // workspace overrides
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("Corrupted config", () => {
|
|
137
|
+
it("should backup corrupted config and generate fresh", () => {
|
|
138
|
+
const configPath = join(globalDir, "subagents.json");
|
|
139
|
+
const backupPath = configPath + ".bak";
|
|
140
|
+
|
|
141
|
+
// Write corrupted JSON
|
|
142
|
+
writeFileSync(configPath, "{ invalid json !!!");
|
|
143
|
+
|
|
144
|
+
const config = initConfig(globalDir, workspaceDir);
|
|
145
|
+
|
|
146
|
+
// Should have created backup
|
|
147
|
+
assert.equal(existsSync(backupPath), true, "Backup should exist");
|
|
148
|
+
// Backup should contain corrupted content
|
|
149
|
+
assert.equal(readFileSync(backupPath, "utf-8"), "{ invalid json !!!");
|
|
150
|
+
// New config should be defaults
|
|
151
|
+
assert.deepEqual(config, DEFAULT_CONFIG);
|
|
152
|
+
// New file should be valid JSON
|
|
153
|
+
const content = readFileSync(configPath, "utf-8");
|
|
154
|
+
assert.deepEqual(JSON.parse(content), DEFAULT_CONFIG);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should handle completely empty file", () => {
|
|
158
|
+
const configPath = join(globalDir, "subagents.json");
|
|
159
|
+
writeFileSync(configPath, "");
|
|
160
|
+
|
|
161
|
+
const config = initConfig(globalDir, workspaceDir);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(config, DEFAULT_CONFIG);
|
|
164
|
+
assert.equal(existsSync(configPath + ".bak"), true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should handle non-object JSON", () => {
|
|
168
|
+
const configPath = join(globalDir, "subagents.json");
|
|
169
|
+
writeFileSync(configPath, '"just a string"');
|
|
170
|
+
|
|
171
|
+
const config = initConfig(globalDir, workspaceDir);
|
|
172
|
+
|
|
173
|
+
// JSON.parse succeeds but returns string, not object
|
|
174
|
+
// loadConfigFromPath checks typeof === "object"
|
|
175
|
+
assert.deepEqual(config, DEFAULT_CONFIG);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("Workspace override", () => {
|
|
180
|
+
it("should merge workspace config with global", () => {
|
|
181
|
+
const globalConfig = { maxConcurrent: 4, enabled: true, types: { explore: { enabled: true } } };
|
|
182
|
+
const workspaceConfig = { maxConcurrent: 8, types: { work: { enabled: false } } };
|
|
183
|
+
|
|
184
|
+
writeFileSync(join(globalDir, "subagents.json"), JSON.stringify(globalConfig));
|
|
185
|
+
writeFileSync(join(workspaceDir, "subagents.json"), JSON.stringify(workspaceConfig));
|
|
186
|
+
|
|
187
|
+
const config = initConfig(globalDir, workspaceDir);
|
|
188
|
+
|
|
189
|
+
assert.equal(config.maxConcurrent, 8); // workspace overrides
|
|
190
|
+
assert.equal(config.enabled, true); // global preserved
|
|
191
|
+
assert.deepEqual(config.types.explore, { enabled: true }); // global preserved
|
|
192
|
+
assert.deepEqual(config.types.work, { enabled: false }); // workspace added
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should override specific fields only", () => {
|
|
196
|
+
const globalConfig = {
|
|
197
|
+
maxConcurrent: 4,
|
|
198
|
+
enabled: true,
|
|
199
|
+
types: { explore: { enabled: true }, work: { enabled: true } },
|
|
200
|
+
};
|
|
201
|
+
const workspaceConfig = { enabled: false };
|
|
202
|
+
|
|
203
|
+
writeFileSync(join(globalDir, "subagents.json"), JSON.stringify(globalConfig));
|
|
204
|
+
writeFileSync(join(workspaceDir, "subagents.json"), JSON.stringify(workspaceConfig));
|
|
205
|
+
|
|
206
|
+
const config = initConfig(globalDir, workspaceDir);
|
|
207
|
+
|
|
208
|
+
assert.equal(config.maxConcurrent, 4); // global preserved
|
|
209
|
+
assert.equal(config.enabled, false); // workspace overrides
|
|
210
|
+
assert.deepEqual(config.types, { explore: { enabled: true }, work: { enabled: true } }); // global preserved
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should handle empty workspace config", () => {
|
|
214
|
+
const globalConfig = { maxConcurrent: 4, enabled: true, types: {} };
|
|
215
|
+
writeFileSync(join(globalDir, "subagents.json"), JSON.stringify(globalConfig));
|
|
216
|
+
writeFileSync(join(workspaceDir, "subagents.json"), "{}");
|
|
217
|
+
|
|
218
|
+
const config = initConfig(globalDir, workspaceDir);
|
|
219
|
+
|
|
220
|
+
assert.deepEqual(config, globalConfig);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("Atomic writes", () => {
|
|
225
|
+
it("should write config atomically", () => {
|
|
226
|
+
const configPath = join(globalDir, "subagents.json");
|
|
227
|
+
const config = { maxConcurrent: 8, enabled: false, types: {} };
|
|
228
|
+
|
|
229
|
+
writeConfigAtomic(configPath, config);
|
|
230
|
+
|
|
231
|
+
// Should have main file
|
|
232
|
+
assert.equal(existsSync(configPath), true);
|
|
233
|
+
// Should not have temp file
|
|
234
|
+
assert.equal(existsSync(configPath + ".tmp"), false);
|
|
235
|
+
// Content should be valid
|
|
236
|
+
const content = readFileSync(configPath, "utf-8");
|
|
237
|
+
assert.deepEqual(JSON.parse(content), config);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: ESC propagation — all children abort on parent ESC
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - forwardAbortSignal wires parent signal to child session
|
|
6
|
+
* - abortAll stops all running agents
|
|
7
|
+
* - All agents stop within reasonable time
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, mock, beforeEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
|
|
13
|
+
// Mock AbortController to track abort calls
|
|
14
|
+
function createMockAbortController() {
|
|
15
|
+
let aborted = false;
|
|
16
|
+
const listeners: Array<() => void> = [];
|
|
17
|
+
return {
|
|
18
|
+
get signal() {
|
|
19
|
+
return {
|
|
20
|
+
aborted,
|
|
21
|
+
addEventListener: (_event: string, listener: () => void) => {
|
|
22
|
+
listeners.push(listener);
|
|
23
|
+
},
|
|
24
|
+
removeEventListener: (_event: string, listener: () => void) => {
|
|
25
|
+
const idx = listeners.indexOf(listener);
|
|
26
|
+
if (idx !== -1) listeners.splice(idx, 1);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
abort() {
|
|
31
|
+
aborted = true;
|
|
32
|
+
for (const listener of listeners) listener();
|
|
33
|
+
},
|
|
34
|
+
get wasAborted() {
|
|
35
|
+
return aborted;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("ESC Propagation", () => {
|
|
41
|
+
describe("forwardAbortSignal", () => {
|
|
42
|
+
it("should call session.abort() when signal fires", () => {
|
|
43
|
+
// Simulate the forwardAbortSignal logic from agent-runner.ts
|
|
44
|
+
const sessionAborted = { value: false };
|
|
45
|
+
const session = { abort: () => { sessionAborted.value = true; } };
|
|
46
|
+
const controller = createMockAbortController();
|
|
47
|
+
|
|
48
|
+
// Wire abort signal
|
|
49
|
+
const onAbort = () => session.abort();
|
|
50
|
+
controller.signal.addEventListener("abort", onAbort);
|
|
51
|
+
|
|
52
|
+
// Trigger abort
|
|
53
|
+
controller.abort();
|
|
54
|
+
|
|
55
|
+
assert.equal(sessionAborted.value, true, "Session should be aborted");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should not call session.abort() if signal not fired", () => {
|
|
59
|
+
const sessionAborted = { value: false };
|
|
60
|
+
const session = { abort: () => { sessionAborted.value = true; } };
|
|
61
|
+
const controller = createMockAbortController();
|
|
62
|
+
|
|
63
|
+
const onAbort = () => session.abort();
|
|
64
|
+
controller.signal.addEventListener("abort", onAbort);
|
|
65
|
+
|
|
66
|
+
// Don't abort
|
|
67
|
+
assert.equal(sessionAborted.value, false, "Session should not be aborted");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should cleanup listener when returned function called", () => {
|
|
71
|
+
const controller = createMockAbortController();
|
|
72
|
+
let callCount = 0;
|
|
73
|
+
const onAbort = () => { callCount++; };
|
|
74
|
+
controller.signal.addEventListener("abort", onAbort);
|
|
75
|
+
|
|
76
|
+
// Simulate cleanup
|
|
77
|
+
const cleanup = () => controller.signal.removeEventListener("abort", onAbort);
|
|
78
|
+
cleanup();
|
|
79
|
+
|
|
80
|
+
controller.abort();
|
|
81
|
+
assert.equal(callCount, 0, "Listener should not fire after cleanup");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("abortAll", () => {
|
|
86
|
+
it("should abort all running agents", () => {
|
|
87
|
+
const agents = new Map<string, { abortController: ReturnType<typeof createMockAbortController>; status: string }>();
|
|
88
|
+
|
|
89
|
+
// Create 3 mock agents
|
|
90
|
+
for (let i = 0; i < 3; i++) {
|
|
91
|
+
const controller = createMockAbortController();
|
|
92
|
+
agents.set(`agent-${i}`, {
|
|
93
|
+
abortController: controller,
|
|
94
|
+
status: "running",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Simulate abortAll
|
|
99
|
+
let abortedCount = 0;
|
|
100
|
+
for (const [id, record] of agents) {
|
|
101
|
+
if (record.status === "running") {
|
|
102
|
+
record.abortController.abort();
|
|
103
|
+
record.status = "stopped";
|
|
104
|
+
abortedCount++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
assert.equal(abortedCount, 3, "Should abort all 3 agents");
|
|
109
|
+
for (const [_, record] of agents) {
|
|
110
|
+
assert.equal(record.status, "stopped", "All agents should be stopped");
|
|
111
|
+
assert.equal(record.abortController.wasAborted, true, "All controllers should be aborted");
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should handle queued agents by removing from queue", () => {
|
|
116
|
+
const queue = [
|
|
117
|
+
{ id: "queued-1", status: "queued" },
|
|
118
|
+
{ id: "queued-2", status: "queued" },
|
|
119
|
+
];
|
|
120
|
+
const agents = new Map<string, { status: string }>();
|
|
121
|
+
|
|
122
|
+
for (const item of queue) {
|
|
123
|
+
agents.set(item.id, { status: item.status });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Simulate abortAll for queued
|
|
127
|
+
for (const item of queue) {
|
|
128
|
+
const record = agents.get(item.id);
|
|
129
|
+
if (record) {
|
|
130
|
+
record.status = "stopped";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
queue.length = 0;
|
|
134
|
+
|
|
135
|
+
assert.equal(queue.length, 0, "Queue should be empty");
|
|
136
|
+
for (const [_, record] of agents) {
|
|
137
|
+
assert.equal(record.status, "stopped", "All queued agents should be stopped");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("ESC timing", () => {
|
|
143
|
+
it("should abort within reasonable time", async () => {
|
|
144
|
+
const controller = createMockAbortController();
|
|
145
|
+
let abortedAt: number | null = null;
|
|
146
|
+
const startedAt = Date.now();
|
|
147
|
+
|
|
148
|
+
const onAbort = () => { abortedAt = Date.now(); };
|
|
149
|
+
controller.signal.addEventListener("abort", onAbort);
|
|
150
|
+
|
|
151
|
+
// Simulate abort after small delay
|
|
152
|
+
setTimeout(() => controller.abort(), 10);
|
|
153
|
+
|
|
154
|
+
// Wait for abort
|
|
155
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
156
|
+
|
|
157
|
+
assert.notEqual(abortedAt, null, "Should have aborted");
|
|
158
|
+
const elapsed = abortedAt! - startedAt;
|
|
159
|
+
assert.ok(elapsed < 500, `Abort should happen within 500ms, took ${elapsed}ms`);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: File locking — concurrent writes to same file queue correctly
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Per-file locking works correctly
|
|
6
|
+
* - Same file writes queue (second waits for first)
|
|
7
|
+
* - Different file writes proceed in parallel
|
|
8
|
+
* - Lock release unblocks waiting acquires
|
|
9
|
+
* - releaseAll releases all locks for an agent
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
|
|
15
|
+
// Inline FileLock implementation for testing (avoids TS import issues)
|
|
16
|
+
interface FileLockEntry {
|
|
17
|
+
agentId: string;
|
|
18
|
+
filePath: string;
|
|
19
|
+
promise: Promise<void>;
|
|
20
|
+
release: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class FileLock {
|
|
24
|
+
private locks = new Map<string, FileLockEntry>();
|
|
25
|
+
private queues = new Map<string, Array<() => void>>();
|
|
26
|
+
|
|
27
|
+
async acquire(filePath: string, agentId: string): Promise<() => void> {
|
|
28
|
+
while (this.locks.has(filePath)) {
|
|
29
|
+
await new Promise<void>((resolve) => {
|
|
30
|
+
const queue = this.queues.get(filePath) ?? [];
|
|
31
|
+
queue.push(resolve);
|
|
32
|
+
this.queues.set(filePath, queue);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let releaseFn: () => void;
|
|
37
|
+
const promise = new Promise<void>((resolve) => {
|
|
38
|
+
releaseFn = () => {
|
|
39
|
+
this.locks.delete(filePath);
|
|
40
|
+
resolve();
|
|
41
|
+
const queue = this.queues.get(filePath);
|
|
42
|
+
if (queue && queue.length > 0) {
|
|
43
|
+
const next = queue.shift()!;
|
|
44
|
+
next();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const entry: FileLockEntry = {
|
|
50
|
+
agentId,
|
|
51
|
+
filePath,
|
|
52
|
+
promise,
|
|
53
|
+
release: releaseFn!,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
this.locks.set(filePath, entry);
|
|
57
|
+
return releaseFn!;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isLocked(filePath: string): boolean {
|
|
61
|
+
return this.locks.has(filePath);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getHolder(filePath: string): string | undefined {
|
|
65
|
+
return this.locks.get(filePath)?.agentId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get lockCount(): number {
|
|
69
|
+
return this.locks.size;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
releaseAll(agentId: string): void {
|
|
73
|
+
for (const [filePath, entry] of this.locks) {
|
|
74
|
+
if (entry.agentId === agentId) {
|
|
75
|
+
entry.release();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clear(): void {
|
|
81
|
+
for (const entry of this.locks.values()) {
|
|
82
|
+
entry.release();
|
|
83
|
+
}
|
|
84
|
+
this.locks.clear();
|
|
85
|
+
this.queues.clear();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("FileLock", () => {
|
|
90
|
+
describe("Basic locking", () => {
|
|
91
|
+
it("should acquire lock on unlocked file", async () => {
|
|
92
|
+
const lock = new FileLock();
|
|
93
|
+
const release = await lock.acquire("/src/auth.ts", "agent-1");
|
|
94
|
+
|
|
95
|
+
assert.equal(lock.isLocked("/src/auth.ts"), true);
|
|
96
|
+
assert.equal(lock.getHolder("/src/auth.ts"), "agent-1");
|
|
97
|
+
assert.equal(lock.lockCount, 1);
|
|
98
|
+
|
|
99
|
+
release();
|
|
100
|
+
assert.equal(lock.isLocked("/src/auth.ts"), false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should track multiple locks on different files", async () => {
|
|
104
|
+
const lock = new FileLock();
|
|
105
|
+
const release1 = await lock.acquire("/src/auth.ts", "agent-1");
|
|
106
|
+
const release2 = await lock.acquire("/src/login.ts", "agent-2");
|
|
107
|
+
|
|
108
|
+
assert.equal(lock.lockCount, 2);
|
|
109
|
+
assert.equal(lock.getHolder("/src/auth.ts"), "agent-1");
|
|
110
|
+
assert.equal(lock.getHolder("/src/login.ts"), "agent-2");
|
|
111
|
+
|
|
112
|
+
release1();
|
|
113
|
+
release2();
|
|
114
|
+
assert.equal(lock.lockCount, 0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("Queuing behavior", () => {
|
|
119
|
+
it("should queue second acquire on same file", async () => {
|
|
120
|
+
const lock = new FileLock();
|
|
121
|
+
const events: string[] = [];
|
|
122
|
+
|
|
123
|
+
// First acquire
|
|
124
|
+
const release1 = await lock.acquire("/src/auth.ts", "agent-1");
|
|
125
|
+
events.push("agent-1-acquired");
|
|
126
|
+
|
|
127
|
+
// Second acquire (should queue)
|
|
128
|
+
const acquire2Promise = lock.acquire("/src/auth.ts", "agent-2").then((release) => {
|
|
129
|
+
events.push("agent-2-acquired");
|
|
130
|
+
return release;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// agent-2 should not have acquired yet
|
|
134
|
+
assert.deepEqual(events, ["agent-1-acquired"]);
|
|
135
|
+
|
|
136
|
+
// Release first lock
|
|
137
|
+
release1();
|
|
138
|
+
events.push("agent-1-released");
|
|
139
|
+
|
|
140
|
+
// Wait for agent-2
|
|
141
|
+
const release2 = await acquire2Promise;
|
|
142
|
+
assert.deepEqual(events, ["agent-1-acquired", "agent-1-released", "agent-2-acquired"]);
|
|
143
|
+
|
|
144
|
+
release2();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should queue multiple acquires on same file", async () => {
|
|
148
|
+
const lock = new FileLock();
|
|
149
|
+
const events: string[] = [];
|
|
150
|
+
|
|
151
|
+
const release1 = await lock.acquire("/src/auth.ts", "agent-1");
|
|
152
|
+
events.push("1-acquired");
|
|
153
|
+
|
|
154
|
+
const p2 = lock.acquire("/src/auth.ts", "agent-2").then(r => { events.push("2-acquired"); return r; });
|
|
155
|
+
const p3 = lock.acquire("/src/auth.ts", "agent-3").then(r => { events.push("3-acquired"); return r; });
|
|
156
|
+
|
|
157
|
+
release1();
|
|
158
|
+
const release2 = await p2;
|
|
159
|
+
assert.deepEqual(events, ["1-acquired", "2-acquired"]);
|
|
160
|
+
|
|
161
|
+
release2();
|
|
162
|
+
const release3 = await p3;
|
|
163
|
+
assert.deepEqual(events, ["1-acquired", "2-acquired", "3-acquired"]);
|
|
164
|
+
|
|
165
|
+
release3();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("Parallel different files", () => {
|
|
170
|
+
it("should allow parallel writes to different files", async () => {
|
|
171
|
+
const lock = new FileLock();
|
|
172
|
+
const events: string[] = [];
|
|
173
|
+
|
|
174
|
+
// Both should acquire immediately (different files)
|
|
175
|
+
const release1 = await lock.acquire("/src/auth.ts", "agent-1");
|
|
176
|
+
events.push("auth-acquired");
|
|
177
|
+
|
|
178
|
+
const release2 = await lock.acquire("/src/login.ts", "agent-2");
|
|
179
|
+
events.push("login-acquired");
|
|
180
|
+
|
|
181
|
+
assert.deepEqual(events, ["auth-acquired", "login-acquired"]);
|
|
182
|
+
assert.equal(lock.lockCount, 2);
|
|
183
|
+
|
|
184
|
+
release1();
|
|
185
|
+
release2();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("releaseAll", () => {
|
|
190
|
+
it("should release all locks for a specific agent", async () => {
|
|
191
|
+
const lock = new FileLock();
|
|
192
|
+
|
|
193
|
+
// agent-1 holds 3 files
|
|
194
|
+
await lock.acquire("/src/a.ts", "agent-1");
|
|
195
|
+
await lock.acquire("/src/b.ts", "agent-1");
|
|
196
|
+
await lock.acquire("/src/c.ts", "agent-1");
|
|
197
|
+
|
|
198
|
+
// agent-2 holds 1 file
|
|
199
|
+
await lock.acquire("/src/d.ts", "agent-2");
|
|
200
|
+
|
|
201
|
+
assert.equal(lock.lockCount, 4);
|
|
202
|
+
|
|
203
|
+
// Release all for agent-1
|
|
204
|
+
lock.releaseAll("agent-1");
|
|
205
|
+
|
|
206
|
+
assert.equal(lock.lockCount, 1);
|
|
207
|
+
assert.equal(lock.isLocked("/src/a.ts"), false);
|
|
208
|
+
assert.equal(lock.isLocked("/src/b.ts"), false);
|
|
209
|
+
assert.equal(lock.isLocked("/src/c.ts"), false);
|
|
210
|
+
assert.equal(lock.isLocked("/src/d.ts"), true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should unblock queued acquires when releasing all", async () => {
|
|
214
|
+
const lock = new FileLock();
|
|
215
|
+
const events: string[] = [];
|
|
216
|
+
|
|
217
|
+
const release1 = await lock.acquire("/src/a.ts", "agent-1");
|
|
218
|
+
const p2 = lock.acquire("/src/a.ts", "agent-2").then(r => { events.push("agent-2-acquired"); return r; });
|
|
219
|
+
|
|
220
|
+
// Release all for agent-1
|
|
221
|
+
lock.releaseAll("agent-1");
|
|
222
|
+
|
|
223
|
+
const release2 = await p2;
|
|
224
|
+
assert.deepEqual(events, ["agent-2-acquired"]);
|
|
225
|
+
|
|
226
|
+
release2();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("clear", () => {
|
|
231
|
+
it("should release all locks and clear queues", async () => {
|
|
232
|
+
const lock = new FileLock();
|
|
233
|
+
|
|
234
|
+
await lock.acquire("/src/a.ts", "agent-1");
|
|
235
|
+
await lock.acquire("/src/b.ts", "agent-2");
|
|
236
|
+
|
|
237
|
+
lock.clear();
|
|
238
|
+
|
|
239
|
+
assert.equal(lock.lockCount, 0);
|
|
240
|
+
assert.equal(lock.isLocked("/src/a.ts"), false);
|
|
241
|
+
assert.equal(lock.isLocked("/src/b.ts"), false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Workflow integration — `/unipi:work` with subagent support
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - spawn_helper and get_helper_result tools are properly defined
|
|
6
|
+
* - Agent types (explore, work) are correctly configured
|
|
7
|
+
* - Concurrency limit is respected
|
|
8
|
+
* - Custom agent type loading works
|
|
9
|
+
* - System prompt builder generates correct prompts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
|
|
15
|
+
// Test type definitions
|
|
16
|
+
const BUILTIN_TYPES = ["explore", "work"] as const;
|
|
17
|
+
|
|
18
|
+
interface AgentConfig {
|
|
19
|
+
name: string;
|
|
20
|
+
displayName?: string;
|
|
21
|
+
description: string;
|
|
22
|
+
builtinToolNames?: string[];
|
|
23
|
+
disallowedTools?: string[];
|
|
24
|
+
extensions: true | string[] | false;
|
|
25
|
+
skills: true | string[] | false;
|
|
26
|
+
model?: string;
|
|
27
|
+
thinking?: string;
|
|
28
|
+
maxTurns?: number;
|
|
29
|
+
systemPrompt: string;
|
|
30
|
+
promptMode: "replace" | "append";
|
|
31
|
+
inheritContext?: boolean;
|
|
32
|
+
runInBackground?: boolean;
|
|
33
|
+
isolated?: boolean;
|
|
34
|
+
memory?: string;
|
|
35
|
+
isDefault?: boolean;
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
source?: "builtin" | "project" | "global";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Test prompt builder
|
|
41
|
+
function buildAgentPrompt(
|
|
42
|
+
config: AgentConfig,
|
|
43
|
+
cwd: string,
|
|
44
|
+
env: { isGitRepo: boolean; branch: string; platform: string },
|
|
45
|
+
parentSystemPrompt: string,
|
|
46
|
+
): string {
|
|
47
|
+
if (config.promptMode === "append") {
|
|
48
|
+
return [
|
|
49
|
+
parentSystemPrompt,
|
|
50
|
+
"",
|
|
51
|
+
"---",
|
|
52
|
+
"",
|
|
53
|
+
`## Agent Role: ${config.displayName ?? config.name}`,
|
|
54
|
+
config.systemPrompt,
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
`# ${config.displayName ?? config.name}`,
|
|
60
|
+
"",
|
|
61
|
+
config.systemPrompt,
|
|
62
|
+
"",
|
|
63
|
+
"---",
|
|
64
|
+
"",
|
|
65
|
+
`Working directory: ${cwd}`,
|
|
66
|
+
`Git: ${env.isGitRepo ? `${env.branch} on ${env.platform}` : "not a git repo"}`,
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Test concurrency manager
|
|
71
|
+
class ConcurrencyManager {
|
|
72
|
+
private maxConcurrent: number;
|
|
73
|
+
private running: number = 0;
|
|
74
|
+
private queue: Array<{ id: string; resolve: () => void }> = [];
|
|
75
|
+
|
|
76
|
+
constructor(maxConcurrent: number) {
|
|
77
|
+
this.maxConcurrent = maxConcurrent;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async acquire(id: string): Promise<() => void> {
|
|
81
|
+
if (this.running >= this.maxConcurrent) {
|
|
82
|
+
await new Promise<void>((resolve) => {
|
|
83
|
+
this.queue.push({ id, resolve });
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.running++;
|
|
88
|
+
|
|
89
|
+
let released = false;
|
|
90
|
+
return () => {
|
|
91
|
+
if (released) return;
|
|
92
|
+
released = true;
|
|
93
|
+
this.running--;
|
|
94
|
+
|
|
95
|
+
// Start next in queue
|
|
96
|
+
if (this.queue.length > 0) {
|
|
97
|
+
const next = this.queue.shift()!;
|
|
98
|
+
next.resolve();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getRunning(): number {
|
|
104
|
+
return this.running;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getQueueLength(): number {
|
|
108
|
+
return this.queue.length;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("Workflow Integration", () => {
|
|
113
|
+
describe("Tool Definitions", () => {
|
|
114
|
+
it("should define spawn_helper tool with correct parameters", () => {
|
|
115
|
+
const toolDef = {
|
|
116
|
+
name: "spawn_helper",
|
|
117
|
+
description: "Launch a sub-agent for parallel work.",
|
|
118
|
+
parameters: {
|
|
119
|
+
type: "object",
|
|
120
|
+
required: ["type", "prompt", "description"],
|
|
121
|
+
properties: {
|
|
122
|
+
type: { type: "string" },
|
|
123
|
+
prompt: { type: "string" },
|
|
124
|
+
description: { type: "string" },
|
|
125
|
+
run_in_background: { type: "boolean" },
|
|
126
|
+
max_turns: { type: "number" },
|
|
127
|
+
model: { type: "string" },
|
|
128
|
+
thinking: { type: "string" },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
assert.equal(toolDef.name, "spawn_helper");
|
|
134
|
+
assert.equal(toolDef.parameters.required.length, 3);
|
|
135
|
+
assert.ok(toolDef.parameters.properties.type);
|
|
136
|
+
assert.ok(toolDef.parameters.properties.prompt);
|
|
137
|
+
assert.ok(toolDef.parameters.properties.description);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should define get_helper_result tool with correct parameters", () => {
|
|
141
|
+
const toolDef = {
|
|
142
|
+
name: "get_helper_result",
|
|
143
|
+
description: "Check status and retrieve results from a background agent.",
|
|
144
|
+
parameters: {
|
|
145
|
+
type: "object",
|
|
146
|
+
required: ["agent_id"],
|
|
147
|
+
properties: {
|
|
148
|
+
agent_id: { type: "string" },
|
|
149
|
+
wait: { type: "boolean" },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
assert.equal(toolDef.name, "get_helper_result");
|
|
155
|
+
assert.equal(toolDef.parameters.required.length, 1);
|
|
156
|
+
assert.ok(toolDef.parameters.properties.agent_id);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("Agent Types", () => {
|
|
161
|
+
it("should have explore and work as builtin types", () => {
|
|
162
|
+
assert.deepEqual(BUILTIN_TYPES, ["explore", "work"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("should define explore agent with read-only tools", () => {
|
|
166
|
+
const exploreConfig: AgentConfig = {
|
|
167
|
+
name: "explore",
|
|
168
|
+
description: "Fast parallel codebase exploration",
|
|
169
|
+
builtinToolNames: ["read", "bash", "grep", "find", "ls"],
|
|
170
|
+
systemPrompt: "You are a read-only exploration agent.",
|
|
171
|
+
promptMode: "replace",
|
|
172
|
+
extensions: true,
|
|
173
|
+
skills: true,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
assert.ok(!exploreConfig.builtinToolNames?.includes("write"));
|
|
177
|
+
assert.ok(!exploreConfig.builtinToolNames?.includes("edit"));
|
|
178
|
+
assert.ok(exploreConfig.builtinToolNames?.includes("read"));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should define work agent with read-write tools", () => {
|
|
182
|
+
const workConfig: AgentConfig = {
|
|
183
|
+
name: "work",
|
|
184
|
+
description: "Parallel file writes with transparent locking",
|
|
185
|
+
builtinToolNames: ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
|
186
|
+
systemPrompt: "You are a read-write work agent.",
|
|
187
|
+
promptMode: "replace",
|
|
188
|
+
extensions: true,
|
|
189
|
+
skills: true,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
assert.ok(workConfig.builtinToolNames?.includes("write"));
|
|
193
|
+
assert.ok(workConfig.builtinToolNames?.includes("edit"));
|
|
194
|
+
assert.ok(workConfig.builtinToolNames?.includes("read"));
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("System Prompt Builder", () => {
|
|
199
|
+
const env = {
|
|
200
|
+
isGitRepo: true,
|
|
201
|
+
branch: "main",
|
|
202
|
+
platform: "GitHub",
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
it("should build replace mode prompt", () => {
|
|
206
|
+
const config: AgentConfig = {
|
|
207
|
+
name: "explore",
|
|
208
|
+
displayName: "Explorer",
|
|
209
|
+
description: "Test",
|
|
210
|
+
systemPrompt: "Find all authentication files.",
|
|
211
|
+
promptMode: "replace",
|
|
212
|
+
extensions: true,
|
|
213
|
+
skills: true,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt");
|
|
217
|
+
|
|
218
|
+
assert.ok(prompt.startsWith("# Explorer"));
|
|
219
|
+
assert.ok(prompt.includes("Find all authentication files."));
|
|
220
|
+
assert.ok(prompt.includes("Working directory: /workspace"));
|
|
221
|
+
assert.ok(!prompt.includes("Parent prompt"));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should build append mode prompt", () => {
|
|
225
|
+
const config: AgentConfig = {
|
|
226
|
+
name: "work",
|
|
227
|
+
displayName: "Worker",
|
|
228
|
+
description: "Test",
|
|
229
|
+
systemPrompt: "Refactor the auth module.",
|
|
230
|
+
promptMode: "append",
|
|
231
|
+
extensions: true,
|
|
232
|
+
skills: true,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const prompt = buildAgentPrompt(config, "/workspace", env, "Parent prompt");
|
|
236
|
+
|
|
237
|
+
assert.ok(prompt.startsWith("Parent prompt"));
|
|
238
|
+
assert.ok(prompt.includes("## Agent Role: Worker"));
|
|
239
|
+
assert.ok(prompt.includes("Refactor the auth module."));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("Concurrency Limit", () => {
|
|
244
|
+
it("should respect max concurrent limit", async () => {
|
|
245
|
+
const manager = new ConcurrencyManager(2);
|
|
246
|
+
const events: string[] = [];
|
|
247
|
+
|
|
248
|
+
// Start 3 agents, only 2 should run immediately
|
|
249
|
+
const release1 = await manager.acquire("agent-1");
|
|
250
|
+
events.push("agent-1-started");
|
|
251
|
+
|
|
252
|
+
const release2 = await manager.acquire("agent-2");
|
|
253
|
+
events.push("agent-2-started");
|
|
254
|
+
|
|
255
|
+
assert.equal(manager.getRunning(), 2);
|
|
256
|
+
assert.deepEqual(events, ["agent-1-started", "agent-2-started"]);
|
|
257
|
+
|
|
258
|
+
// Third agent should queue
|
|
259
|
+
const acquire3Promise = manager.acquire("agent-3").then((release) => {
|
|
260
|
+
events.push("agent-3-started");
|
|
261
|
+
return release;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
assert.equal(manager.getQueueLength(), 1);
|
|
265
|
+
|
|
266
|
+
// Release first agent
|
|
267
|
+
release1();
|
|
268
|
+
const release3 = await acquire3Promise;
|
|
269
|
+
|
|
270
|
+
assert.equal(manager.getRunning(), 2);
|
|
271
|
+
assert.deepEqual(events, ["agent-1-started", "agent-2-started", "agent-3-started"]);
|
|
272
|
+
|
|
273
|
+
release2();
|
|
274
|
+
release3();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("should queue agents in order", async () => {
|
|
278
|
+
const manager = new ConcurrencyManager(1);
|
|
279
|
+
const events: string[] = [];
|
|
280
|
+
|
|
281
|
+
const release1 = await manager.acquire("agent-1");
|
|
282
|
+
events.push("1");
|
|
283
|
+
|
|
284
|
+
const p2 = manager.acquire("agent-2").then(r => { events.push("2"); return r; });
|
|
285
|
+
const p3 = manager.acquire("agent-3").then(r => { events.push("3"); return r; });
|
|
286
|
+
|
|
287
|
+
release1();
|
|
288
|
+
const release2 = await p2;
|
|
289
|
+
release2();
|
|
290
|
+
const release3 = await p3;
|
|
291
|
+
|
|
292
|
+
assert.deepEqual(events, ["1", "2", "3"]);
|
|
293
|
+
release3();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("Custom Agent Loading", () => {
|
|
298
|
+
it("should parse agent markdown frontmatter", () => {
|
|
299
|
+
const markdown = `---
|
|
300
|
+
name: code-checker
|
|
301
|
+
description: Code quality checker
|
|
302
|
+
tools: read, grep, find, bash
|
|
303
|
+
thinking: high
|
|
304
|
+
---
|
|
305
|
+
You are a code quality checker. Review code for issues.`;
|
|
306
|
+
|
|
307
|
+
// Simple frontmatter parser
|
|
308
|
+
const match = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
309
|
+
assert.ok(match);
|
|
310
|
+
|
|
311
|
+
const frontmatter = match![1];
|
|
312
|
+
const body = match![2];
|
|
313
|
+
|
|
314
|
+
assert.ok(frontmatter.includes("name: code-checker"));
|
|
315
|
+
assert.ok(frontmatter.includes("tools: read, grep, find, bash"));
|
|
316
|
+
assert.ok(body.includes("You are a code quality checker."));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("should validate required fields", () => {
|
|
320
|
+
const validAgent = {
|
|
321
|
+
name: "test-agent",
|
|
322
|
+
description: "Test agent",
|
|
323
|
+
systemPrompt: "Do something.",
|
|
324
|
+
promptMode: "replace" as const,
|
|
325
|
+
extensions: true as const,
|
|
326
|
+
skills: true as const,
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
assert.ok(validAgent.name);
|
|
330
|
+
assert.ok(validAgent.description);
|
|
331
|
+
assert.ok(validAgent.systemPrompt);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -5,23 +5,24 @@
|
|
|
5
5
|
* ESC propagation: all children abort on parent ESC
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { defineTool, type ExtensionAPI
|
|
9
|
-
import { Text } from "@mariozechner/pi-tui";
|
|
8
|
+
import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
10
9
|
import { Type } from "@sinclair/typebox";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { emitEvent, MODULES, UNIPI_EVENTS } from "@pi-unipi/core";
|
|
14
|
+
import { AgentManager } from "./agent-manager.js";
|
|
15
|
+
import { initConfig } from "./config.js";
|
|
16
|
+
import { type AgentActivity, BUILTIN_TYPES } from "./types.js";
|
|
17
|
+
import { AgentWidget } from "./widget.js";
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
/** Get info registry from global */
|
|
15
20
|
function getInfoRegistry() {
|
|
16
21
|
const g = globalThis as any;
|
|
17
22
|
return g.__unipi_info_registry;
|
|
18
23
|
}
|
|
19
|
-
import { AgentManager } from "./agent-manager.js";
|
|
20
|
-
import { initConfig, saveGlobalConfig } from "./config.js";
|
|
21
|
-
import { type AgentActivity, type AgentRecord, BUILTIN_TYPES } from "./types.js";
|
|
22
|
-
import { AgentWidget } from "./widget.js";
|
|
23
24
|
|
|
24
|
-
/** Format tokens safely
|
|
25
|
+
/** Format tokens safely */
|
|
25
26
|
function safeFormatTokens(session: any): string {
|
|
26
27
|
if (!session) return "";
|
|
27
28
|
try {
|
|
@@ -35,7 +36,7 @@ function safeFormatTokens(session: any): string {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/** Build result text
|
|
39
|
+
/** Build result text */
|
|
39
40
|
function textResult(msg: string, details?: any) {
|
|
40
41
|
return { content: [{ type: "text" as const, text: msg }], details };
|
|
41
42
|
}
|
|
@@ -45,17 +46,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
45
46
|
const config = initConfig(process.cwd());
|
|
46
47
|
if (!config.enabled) return;
|
|
47
48
|
|
|
49
|
+
// Compute paths at factory time
|
|
50
|
+
const homeDir = homedir();
|
|
51
|
+
const cwd = process.cwd();
|
|
52
|
+
const globalAgentsDir = join(homeDir, ".unipi", "config", "agents");
|
|
53
|
+
const workspaceAgentsDir = join(cwd, ".unipi", "config", "agents");
|
|
54
|
+
|
|
48
55
|
// Activity tracking for widget
|
|
49
56
|
const agentActivity = new Map<string, AgentActivity>();
|
|
50
57
|
|
|
51
58
|
// Create manager with completion callback
|
|
52
59
|
const manager = new AgentManager(
|
|
53
60
|
(record) => {
|
|
54
|
-
// On complete: clean up activity, emit event
|
|
55
61
|
agentActivity.delete(record.id);
|
|
56
62
|
widget.markFinished(record.id);
|
|
57
63
|
widget.update();
|
|
58
|
-
|
|
59
64
|
pi.events.emit("subagents:completed", {
|
|
60
65
|
id: record.id,
|
|
61
66
|
type: record.type,
|
|
@@ -67,7 +72,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
67
72
|
},
|
|
68
73
|
config.maxConcurrent,
|
|
69
74
|
(record) => {
|
|
70
|
-
// On start: emit event
|
|
71
75
|
pi.events.emit("subagents:started", {
|
|
72
76
|
id: record.id,
|
|
73
77
|
type: record.type,
|
|
@@ -79,103 +83,78 @@ export default function (pi: ExtensionAPI) {
|
|
|
79
83
|
// Create widget
|
|
80
84
|
const widget = new AgentWidget(manager, agentActivity);
|
|
81
85
|
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
},
|
|
107
|
-
dataProvider: async () => {
|
|
108
|
-
// Get available agent types
|
|
109
|
-
const types = config.types || {};
|
|
110
|
-
const builtinTypes = ["explore", "work"];
|
|
111
|
-
|
|
112
|
-
// Check for custom agent types in filesystem
|
|
113
|
-
const customTypes: string[] = [];
|
|
86
|
+
// Register info group at factory time (not session_start)
|
|
87
|
+
const registry = getInfoRegistry();
|
|
88
|
+
if (registry) {
|
|
89
|
+
registry.registerGroup({
|
|
90
|
+
id: "subagents",
|
|
91
|
+
name: "Subagents",
|
|
92
|
+
icon: "🤖",
|
|
93
|
+
priority: 80,
|
|
94
|
+
config: {
|
|
95
|
+
showByDefault: true,
|
|
96
|
+
stats: [
|
|
97
|
+
{ id: "maxConcurrent", label: "Max Concurrent", show: true },
|
|
98
|
+
{ id: "activeCount", label: "Active Agents", show: true },
|
|
99
|
+
{ id: "enabled", label: "Enabled", show: true },
|
|
100
|
+
{ id: "types", label: "Available Types", show: true },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
dataProvider: async () => {
|
|
104
|
+
const types = config.types || {};
|
|
105
|
+
const builtinTypes = ["explore", "work"];
|
|
106
|
+
|
|
107
|
+
// Scan for custom agent types
|
|
108
|
+
const customTypes: string[] = [];
|
|
109
|
+
for (const dir of [globalAgentsDir, workspaceAgentsDir]) {
|
|
114
110
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// Check global agents directory
|
|
119
|
-
if (fs.existsSync(globalAgents)) {
|
|
120
|
-
const files = fs.readdirSync(globalAgents);
|
|
121
|
-
for (const file of files) {
|
|
122
|
-
if (file.endsWith(".md")) {
|
|
111
|
+
if (existsSync(dir)) {
|
|
112
|
+
for (const file of readdirSync(dir)) {
|
|
113
|
+
if (file.endsWith(".md") && !customTypes.includes(file.replace(".md", ""))) {
|
|
123
114
|
customTypes.push(file.replace(".md", ""));
|
|
124
115
|
}
|
|
125
116
|
}
|
|
126
117
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
maxConcurrent: { value: String(manager.getMaxConcurrent()) },
|
|
158
|
-
activeCount: { value: String(activeAgents) },
|
|
159
|
-
enabled: { value: config.enabled ? "yes" : "no" },
|
|
160
|
-
types: {
|
|
161
|
-
value: allTypes.length > 0 ? allTypes[0] : "none",
|
|
162
|
-
detail: allTypes.length > 1 ? typeList : undefined,
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
},
|
|
166
|
-
});
|
|
167
|
-
}
|
|
118
|
+
} catch { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const allTypes = [...new Set([...builtinTypes, ...Object.keys(types), ...customTypes])];
|
|
122
|
+
const typeList = allTypes.map(t => {
|
|
123
|
+
const isEnabled = types[t]?.enabled !== false;
|
|
124
|
+
const isBuiltin = builtinTypes.includes(t);
|
|
125
|
+
const scope = customTypes.includes(t) ? "project" : "global";
|
|
126
|
+
return `${t}(${scope})${isEnabled ? "" : " [disabled]"}`;
|
|
127
|
+
}).join(", ");
|
|
128
|
+
|
|
129
|
+
const activeAgents = manager.listAgents().filter(a => a.status === "running").length;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
maxConcurrent: { value: String(manager.getMaxConcurrent()) },
|
|
133
|
+
activeCount: { value: String(activeAgents) },
|
|
134
|
+
enabled: { value: config.enabled ? "yes" : "no" },
|
|
135
|
+
types: {
|
|
136
|
+
value: allTypes.length > 0 ? allTypes[0] : "none",
|
|
137
|
+
detail: allTypes.length > 1 ? typeList : undefined,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Session start: emit MODULE_READY
|
|
145
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
146
|
+
const globalConfig = `${homeDir}/.unipi/config/subagents.json`;
|
|
147
|
+
const workspaceConfig = `${cwd}/.unipi/config/subagents.json`;
|
|
168
148
|
|
|
169
149
|
ctx.ui.notify(
|
|
170
150
|
`UniPi Subagents config:\n` +
|
|
171
151
|
`• Global: ${globalConfig}\n` +
|
|
172
|
-
`• Global agents: ${
|
|
152
|
+
`• Global agents: ${globalAgentsDir}\n` +
|
|
173
153
|
`• Workspace: ${workspaceConfig}\n` +
|
|
174
|
-
`• Workspace agents: ${
|
|
154
|
+
`• Workspace agents: ${workspaceAgentsDir}`,
|
|
175
155
|
"info",
|
|
176
156
|
);
|
|
177
157
|
|
|
178
|
-
// Emit module ready event
|
|
179
158
|
emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
|
|
180
159
|
name: MODULES.SUBAGENTS || "subagents",
|
|
181
160
|
version: "0.1.8",
|
|
@@ -305,11 +284,9 @@ Guidelines:
|
|
|
305
284
|
const modelInput = params.model as string | undefined;
|
|
306
285
|
const thinkingLevel = params.thinking as any | undefined;
|
|
307
286
|
|
|
308
|
-
// Create activity tracker
|
|
309
287
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(maxTurns);
|
|
310
288
|
|
|
311
289
|
if (runInBackground) {
|
|
312
|
-
// Background execution
|
|
313
290
|
const id = manager.spawn(pi, ctx, type, prompt, {
|
|
314
291
|
description,
|
|
315
292
|
maxTurns,
|
|
@@ -342,7 +319,6 @@ Guidelines:
|
|
|
342
319
|
// Foreground execution
|
|
343
320
|
let spinnerFrame = 0;
|
|
344
321
|
const startedAt = Date.now();
|
|
345
|
-
let fgId: string | undefined;
|
|
346
322
|
|
|
347
323
|
const streamUpdate = () => {
|
|
348
324
|
onUpdate?.({
|
|
@@ -382,11 +358,6 @@ Guidelines:
|
|
|
382
358
|
|
|
383
359
|
clearInterval(spinnerInterval);
|
|
384
360
|
|
|
385
|
-
if (fgId) {
|
|
386
|
-
agentActivity.delete(fgId);
|
|
387
|
-
widget.markFinished(fgId);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
361
|
const tokenText = safeFormatTokens(bgState.session);
|
|
391
362
|
const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
|
|
392
363
|
|