@pi-unipi/unipi 0.1.2 → 0.1.4
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 +7 -3
- package/packages/memory/index.ts +62 -35
- package/packages/memory/skills/memory/SKILL.md +30 -8
- package/packages/subagents/src/__tests__/config.test.ts +240 -0
- package/packages/subagents/src/__tests__/esc-propagation.test.ts +162 -0
- package/packages/subagents/src/__tests__/file-lock.test.ts +244 -0
- package/packages/subagents/src/__tests__/workflow-integration.test.ts +334 -0
- package/packages/subagents/src/agent-manager.ts +323 -0
- package/packages/subagents/src/agent-runner.ts +306 -0
- package/packages/subagents/src/config.ts +147 -0
- package/packages/subagents/src/custom-agents.ts +118 -0
- package/packages/subagents/src/file-lock.ts +102 -0
- package/packages/subagents/src/index.ts +429 -0
- package/packages/subagents/src/model-resolver.ts +79 -0
- package/packages/subagents/src/prompts.ts +39 -0
- package/packages/subagents/src/types.ts +86 -0
- package/packages/subagents/src/widget.ts +193 -0
- package/packages/web-api/README.md +179 -0
- package/packages/web-api/skills/web/SKILL.md +108 -0
- package/packages/web-api/src/cache.ts +240 -0
- package/packages/web-api/src/commands.ts +45 -0
- package/packages/web-api/src/index.ts +100 -0
- package/packages/web-api/src/providers/base.ts +108 -0
- package/packages/web-api/src/providers/duckduckgo.ts +115 -0
- package/packages/web-api/src/providers/firecrawl.ts +105 -0
- package/packages/web-api/src/providers/jina-reader.ts +89 -0
- package/packages/web-api/src/providers/jina-search.ts +88 -0
- package/packages/web-api/src/providers/llm-summarize.ts +71 -0
- package/packages/web-api/src/providers/perplexity.ts +191 -0
- package/packages/web-api/src/providers/registry.ts +128 -0
- package/packages/web-api/src/providers/serpapi.ts +86 -0
- package/packages/web-api/src/providers/tavily.ts +95 -0
- package/packages/web-api/src/settings.ts +263 -0
- package/packages/web-api/src/tools.ts +329 -0
- package/packages/web-api/src/tui/provider-selector.ts +71 -0
- package/packages/web-api/src/tui/settings-dialog.ts +177 -0
- package/packages/workflow/index.ts +11 -2
- package/packages/workflow/skills/brainstorm/SKILL.md +1 -0
- package/packages/workflow/skills/plan/SKILL.md +33 -13
- package/packages/workflow/skills/review-work/SKILL.md +12 -4
- package/packages/workflow/skills/work/SKILL.md +31 -9
- package/packages/workflow/skills/worktree-list/SKILL.md +23 -12
- package/packages/workflow/skills/worktree-merge/SKILL.md +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/unipi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "All-in-one extension suite for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"typecheck": "npx tsc --noEmit --skipLibCheck",
|
|
27
|
+
"test": "npm test --workspaces",
|
|
27
28
|
"publish:all": "npm publish --workspaces --access public"
|
|
28
29
|
},
|
|
29
30
|
"files": [
|
|
30
31
|
"packages/*/index.ts",
|
|
32
|
+
"packages/*/src/**/*.ts",
|
|
31
33
|
"packages/*/extensions/**/*.ts",
|
|
32
34
|
"packages/*/skills/**/*",
|
|
33
35
|
"packages/*/README.md"
|
|
@@ -39,7 +41,8 @@
|
|
|
39
41
|
"node_modules/@pi-unipi/memory/index.ts",
|
|
40
42
|
"node_modules/@pi-unipi/info-screen/index.ts",
|
|
41
43
|
"node_modules/@pi-unipi/subagents/src/index.ts",
|
|
42
|
-
"node_modules/@pi-unipi/btw/extensions/btw.ts"
|
|
44
|
+
"node_modules/@pi-unipi/btw/extensions/btw.ts",
|
|
45
|
+
"node_modules/@pi-unipi/web-api/src/index.ts"
|
|
43
46
|
],
|
|
44
47
|
"skills": [
|
|
45
48
|
"node_modules/@pi-unipi/workflow/skills",
|
|
@@ -61,7 +64,8 @@
|
|
|
61
64
|
"@pi-unipi/memory": "*",
|
|
62
65
|
"@pi-unipi/info-screen": "*",
|
|
63
66
|
"@pi-unipi/subagents": "*",
|
|
64
|
-
"@pi-unipi/btw": "*"
|
|
67
|
+
"@pi-unipi/btw": "*",
|
|
68
|
+
"@pi-unipi/web-api": "*"
|
|
65
69
|
},
|
|
66
70
|
"devDependencies": {
|
|
67
71
|
"@types/node": "^25.6.0",
|
package/packages/memory/index.ts
CHANGED
|
@@ -26,8 +26,9 @@ import {
|
|
|
26
26
|
searchAllProjects,
|
|
27
27
|
listAllProjects,
|
|
28
28
|
} from "./storage.js";
|
|
29
|
-
import { registerMemoryTools, MEMORY_TOOLS } from "./tools.js";
|
|
29
|
+
import { registerMemoryTools, MEMORY_TOOLS, GLOBAL_SEARCH_ALIAS } from "./tools.js";
|
|
30
30
|
import { registerMemoryCommands } from "./commands.js";
|
|
31
|
+
import { isEmbeddingReady, hasModelChanged } from "./settings.js";
|
|
31
32
|
|
|
32
33
|
/** Package version */
|
|
33
34
|
const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
|
|
@@ -47,6 +48,10 @@ function getStorage(): MemoryStorage {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export default function (pi: ExtensionAPI) {
|
|
51
|
+
// Lifecycle state — tracks whether recall/store have happened this session
|
|
52
|
+
let recallDone = false;
|
|
53
|
+
let storeDone = false;
|
|
54
|
+
|
|
50
55
|
// Register skills directory
|
|
51
56
|
const skillsDir = new URL("./skills", import.meta.url).pathname;
|
|
52
57
|
pi.on("resources_discover", async (_event, _ctx) => {
|
|
@@ -56,11 +61,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
56
61
|
});
|
|
57
62
|
|
|
58
63
|
// Register tools and commands
|
|
59
|
-
registerMemoryTools(pi, getStorage);
|
|
64
|
+
registerMemoryTools(pi, getStorage, () => { recallDone = true; storeDone = true; });
|
|
60
65
|
registerMemoryCommands(pi, getStorage);
|
|
61
66
|
|
|
62
67
|
// Session lifecycle
|
|
63
68
|
pi.on("session_start", async (_event, ctx) => {
|
|
69
|
+
// Reset lifecycle flags
|
|
70
|
+
recallDone = false;
|
|
71
|
+
storeDone = false;
|
|
72
|
+
|
|
64
73
|
// Initialize project storage
|
|
65
74
|
const projectName = getProjectName(ctx.cwd);
|
|
66
75
|
projectStorage = new MemoryStorage(projectName);
|
|
@@ -78,13 +87,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
78
87
|
"unipi:memory-forget",
|
|
79
88
|
"unipi:global-memory-search",
|
|
80
89
|
"unipi:global-memory-list",
|
|
90
|
+
"unipi:memory-settings",
|
|
81
91
|
],
|
|
82
92
|
tools: [
|
|
83
93
|
MEMORY_TOOLS.STORE,
|
|
84
94
|
MEMORY_TOOLS.SEARCH,
|
|
85
95
|
MEMORY_TOOLS.DELETE,
|
|
86
96
|
MEMORY_TOOLS.LIST,
|
|
87
|
-
|
|
97
|
+
GLOBAL_SEARCH_ALIAS,
|
|
88
98
|
MEMORY_TOOLS.GLOBAL_LIST,
|
|
89
99
|
],
|
|
90
100
|
});
|
|
@@ -146,59 +156,76 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
156
|
const projectCount = projectStorage.listAll().length;
|
|
147
157
|
const allMemories = listAllProjects();
|
|
148
158
|
const projectCountAll = allMemories.length;
|
|
159
|
+
const vecReady = isEmbeddingReady();
|
|
160
|
+
const vecIcon = vecReady ? "⚡" : "📝";
|
|
149
161
|
ctx.ui.setStatus(
|
|
150
162
|
"unipi-memory",
|
|
151
|
-
|
|
163
|
+
`${vecIcon} memory ${projectCount}p/${projectCountAll}all${hasModelChanged() ? " ⚠" : ""}`
|
|
152
164
|
);
|
|
153
165
|
}
|
|
154
166
|
});
|
|
155
167
|
|
|
156
|
-
// Inject memory
|
|
168
|
+
// Inject memory recall reminder at agent start (hidden message, not system prompt)
|
|
157
169
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
170
|
+
if (recallDone) return;
|
|
158
171
|
if (!projectStorage) return;
|
|
159
172
|
|
|
160
173
|
const projectName = getProjectName(ctx.cwd);
|
|
161
174
|
const projectMemories = projectStorage.listAll();
|
|
162
175
|
|
|
163
176
|
if (projectMemories.length === 0) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
let injection = "\n\n<memory>\n";
|
|
168
|
-
injection += `Available memories for project "${projectName}":\n\n`;
|
|
169
|
-
|
|
170
|
-
// Project memories
|
|
171
|
-
for (const m of projectMemories) {
|
|
172
|
-
injection += `- ${m.title}\n`;
|
|
177
|
+
recallDone = true; // Nothing to recall, skip
|
|
178
|
+
return;
|
|
173
179
|
}
|
|
174
180
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
injection += "</memory>";
|
|
181
|
+
const titleList = projectMemories.slice(0, 20).map(m => `- ${m.title}`).join("\n");
|
|
182
|
+
const extra = projectMemories.length > 20 ? `\n... and ${projectMemories.length - 20} more` : "";
|
|
178
183
|
|
|
179
184
|
return {
|
|
180
|
-
|
|
185
|
+
message: {
|
|
186
|
+
customType: "unipi-memory-recall-reminder",
|
|
187
|
+
content: [
|
|
188
|
+
"## 🧠 Memory System Active",
|
|
189
|
+
"",
|
|
190
|
+
`You have ${projectMemories.length} memories stored for project "${projectName}".`,
|
|
191
|
+
"**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
|
|
192
|
+
"",
|
|
193
|
+
"Available memories:",
|
|
194
|
+
titleList + extra,
|
|
195
|
+
"",
|
|
196
|
+
"**AFTER completing the task**, if you learned something non-obvious,",
|
|
197
|
+
"call `memory_store` to save it for future sessions.",
|
|
198
|
+
"",
|
|
199
|
+
"Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
|
|
200
|
+
].join("\n"),
|
|
201
|
+
display: false,
|
|
202
|
+
},
|
|
181
203
|
};
|
|
182
204
|
});
|
|
183
205
|
|
|
184
|
-
//
|
|
185
|
-
pi.on("
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
206
|
+
// After each agent response, remind LLM to save if it hasn't yet
|
|
207
|
+
pi.on("agent_end", async (_event, _ctx) => {
|
|
208
|
+
if (storeDone || !recallDone) return;
|
|
209
|
+
|
|
210
|
+
pi.sendMessage(
|
|
211
|
+
{
|
|
212
|
+
customType: "unipi-memory-retro-reminder",
|
|
213
|
+
content: [
|
|
214
|
+
"**🧠 Memory reminder:** If you learned something non-obvious in this task,",
|
|
215
|
+
"call `memory_store` to save it as a memory for future sessions.",
|
|
216
|
+
"Update existing memories instead of creating duplicates.",
|
|
217
|
+
].join(" "),
|
|
218
|
+
display: false,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
deliverAs: "nextTurn",
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
});
|
|
199
225
|
|
|
200
|
-
|
|
201
|
-
|
|
226
|
+
// After compaction, reset recall state so reminder re-injects
|
|
227
|
+
pi.on("session_compact", async (_event, _ctx) => {
|
|
228
|
+
recallDone = false;
|
|
202
229
|
});
|
|
203
230
|
|
|
204
231
|
// Cleanup on shutdown
|
|
@@ -9,7 +9,6 @@ allowed-tools:
|
|
|
9
9
|
- memory_search
|
|
10
10
|
- memory_delete
|
|
11
11
|
- memory_list
|
|
12
|
-
- global_memory_store
|
|
13
12
|
- global_memory_search
|
|
14
13
|
- global_memory_list
|
|
15
14
|
- read
|
|
@@ -87,16 +86,18 @@ memory_list()
|
|
|
87
86
|
memory_delete(title: "auth_jwt_prefer_refresh_tokens")
|
|
88
87
|
```
|
|
89
88
|
|
|
90
|
-
##
|
|
89
|
+
## Search Scope
|
|
91
90
|
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
`memory_search` searches ALL projects by default. Use `scope` param to narrow:
|
|
92
|
+
|
|
93
|
+
| Action | Scope | Tool |
|
|
94
|
+
|--------|-------|------|
|
|
94
95
|
| **Store** | Always project-scoped | `memory_store` |
|
|
95
|
-
| **Search
|
|
96
|
-
| **Search
|
|
96
|
+
| **Search all projects** | Cross-project (default) | `memory_search(query)` or `memory_search(query, scope="all")` |
|
|
97
|
+
| **Search this project** | Current project only | `memory_search(query, scope="project")` |
|
|
97
98
|
| **List all** | Cross-project | `global_memory_list` |
|
|
98
99
|
|
|
99
|
-
**All memories are project-scoped.** When you store a memory, it belongs to the current project.
|
|
100
|
+
**All memories are project-scoped.** When you store a memory, it belongs to the current project. `memory_search` searches everything by default — no need to call a separate global search.
|
|
100
101
|
|
|
101
102
|
## Update-First Principle
|
|
102
103
|
|
|
@@ -108,7 +109,27 @@ memory_delete(title: "auth_jwt_prefer_refresh_tokens")
|
|
|
108
109
|
|
|
109
110
|
This prevents memory duplication and keeps memory clean.
|
|
110
111
|
|
|
111
|
-
##
|
|
112
|
+
## Vector Search (Embeddings)
|
|
113
|
+
|
|
114
|
+
Memory supports vector similarity search via OpenRouter API.
|
|
115
|
+
|
|
116
|
+
### Setup
|
|
117
|
+
1. Run `/unipi:memory-settings`
|
|
118
|
+
2. Add your OpenRouter API key
|
|
119
|
+
3. Select embedding model (default: `openai/text-embedding-3-small`)
|
|
120
|
+
|
|
121
|
+
### How it works
|
|
122
|
+
- Embeddings are generated when storing/searching memories
|
|
123
|
+
- Search combines **vector similarity** + **fuzzy text matching** for best results
|
|
124
|
+
- Vector search finds semantically similar memories even without exact keyword matches
|
|
125
|
+
|
|
126
|
+
### Model compatibility
|
|
127
|
+
⚠ **Different embedding models produce incompatible vectors.**
|
|
128
|
+
If you switch models, existing embeddings won't match new searches.
|
|
129
|
+
Use `/unipi:memory-settings` → "Re-embed All Memories" to fix.
|
|
130
|
+
|
|
131
|
+
### No API key?
|
|
132
|
+
Falls back to fuzzy text-only search. Still works, just less semantic.
|
|
112
133
|
|
|
113
134
|
When the user runs `/unipi:memory-consolidate` or during compaction:
|
|
114
135
|
|
|
@@ -149,3 +170,4 @@ You can read these files directly with the `read` tool for full context.
|
|
|
149
170
|
| Use vague titles | Use specific `<category>_<detail>` format |
|
|
150
171
|
| Store in wrong scope | Project-specific = project scope, universal = global |
|
|
151
172
|
| Forget to update | When context changes, update the memory |
|
|
173
|
+
| Switch embedding models without re-embedding | Re-embed or accept fuzzy-only fallback |
|
|
@@ -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
|
+
});
|