@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.
Files changed (43) hide show
  1. package/package.json +7 -3
  2. package/packages/memory/index.ts +62 -35
  3. package/packages/memory/skills/memory/SKILL.md +30 -8
  4. package/packages/subagents/src/__tests__/config.test.ts +240 -0
  5. package/packages/subagents/src/__tests__/esc-propagation.test.ts +162 -0
  6. package/packages/subagents/src/__tests__/file-lock.test.ts +244 -0
  7. package/packages/subagents/src/__tests__/workflow-integration.test.ts +334 -0
  8. package/packages/subagents/src/agent-manager.ts +323 -0
  9. package/packages/subagents/src/agent-runner.ts +306 -0
  10. package/packages/subagents/src/config.ts +147 -0
  11. package/packages/subagents/src/custom-agents.ts +118 -0
  12. package/packages/subagents/src/file-lock.ts +102 -0
  13. package/packages/subagents/src/index.ts +429 -0
  14. package/packages/subagents/src/model-resolver.ts +79 -0
  15. package/packages/subagents/src/prompts.ts +39 -0
  16. package/packages/subagents/src/types.ts +86 -0
  17. package/packages/subagents/src/widget.ts +193 -0
  18. package/packages/web-api/README.md +179 -0
  19. package/packages/web-api/skills/web/SKILL.md +108 -0
  20. package/packages/web-api/src/cache.ts +240 -0
  21. package/packages/web-api/src/commands.ts +45 -0
  22. package/packages/web-api/src/index.ts +100 -0
  23. package/packages/web-api/src/providers/base.ts +108 -0
  24. package/packages/web-api/src/providers/duckduckgo.ts +115 -0
  25. package/packages/web-api/src/providers/firecrawl.ts +105 -0
  26. package/packages/web-api/src/providers/jina-reader.ts +89 -0
  27. package/packages/web-api/src/providers/jina-search.ts +88 -0
  28. package/packages/web-api/src/providers/llm-summarize.ts +71 -0
  29. package/packages/web-api/src/providers/perplexity.ts +191 -0
  30. package/packages/web-api/src/providers/registry.ts +128 -0
  31. package/packages/web-api/src/providers/serpapi.ts +86 -0
  32. package/packages/web-api/src/providers/tavily.ts +95 -0
  33. package/packages/web-api/src/settings.ts +263 -0
  34. package/packages/web-api/src/tools.ts +329 -0
  35. package/packages/web-api/src/tui/provider-selector.ts +71 -0
  36. package/packages/web-api/src/tui/settings-dialog.ts +177 -0
  37. package/packages/workflow/index.ts +11 -2
  38. package/packages/workflow/skills/brainstorm/SKILL.md +1 -0
  39. package/packages/workflow/skills/plan/SKILL.md +33 -13
  40. package/packages/workflow/skills/review-work/SKILL.md +12 -4
  41. package/packages/workflow/skills/work/SKILL.md +31 -9
  42. package/packages/workflow/skills/worktree-list/SKILL.md +23 -12
  43. 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.2",
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",
@@ -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
- MEMORY_TOOLS.GLOBAL_SEARCH,
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
- `🧠 memory ${projectCount}p/${projectCountAll}all`
163
+ `${vecIcon} memory ${projectCount}p/${projectCountAll}all${hasModelChanged() ? " ⚠" : ""}`
152
164
  );
153
165
  }
154
166
  });
155
167
 
156
- // Inject memory titles at session start
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
- return; // No memories to inject
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
- injection += "\nUse memory_search to retrieve full content. Use memory_store to save new memories.\n";
176
- injection += "Use global_memory_search to search across ALL projects.\n";
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
- systemPrompt: event.systemPrompt + injection,
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
- // Auto-consolidation on compaction
185
- pi.on("session_before_compact", async (event, ctx) => {
186
- const { preparation } = event;
187
-
188
- // Extract summary text
189
- const summary = preparation.previousSummary || "";
190
-
191
- if (!summary || summary.length < 100) {
192
- // Summary too short to extract memories from
193
- return;
194
- }
195
-
196
- // For now, just log that consolidation would happen
197
- // Future: Use LLM to extract memories
198
- console.log("[unipi/memory] Auto-consolidation triggered, summary length:", summary.length);
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
- // Don't modify the compaction summary - return unchanged
201
- return {};
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
- ## Project vs Cross-Project Search
89
+ ## Search Scope
91
90
 
92
- | Action | Scope | Tools |
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 this project** | Current project only | `memory_search` |
96
- | **Search all projects** | Cross-project | `global_memory_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. Use `global_memory_search` to search across ALL projects when looking for past work or user preferences.
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
- ## Consolidation
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
+ });