@pi-unipi/subagents 2.4.0 → 2.4.1

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 (66) hide show
  1. package/README.md +3 -1
  2. package/dist/agent-manager.d.ts +81 -0
  3. package/dist/agent-manager.d.ts.map +1 -0
  4. package/dist/agent-manager.js +292 -0
  5. package/dist/agent-manager.js.map +1 -0
  6. package/dist/agent-runner.d.ts +51 -0
  7. package/dist/agent-runner.d.ts.map +1 -0
  8. package/dist/agent-runner.js +262 -0
  9. package/dist/agent-runner.js.map +1 -0
  10. package/dist/config.d.ts +24 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +132 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/conversation-viewer.d.ts +40 -0
  15. package/dist/conversation-viewer.d.ts.map +1 -0
  16. package/dist/conversation-viewer.js +276 -0
  17. package/dist/conversation-viewer.js.map +1 -0
  18. package/dist/core-compat.d.ts +14 -0
  19. package/dist/core-compat.d.ts.map +1 -0
  20. package/dist/core-compat.js +24 -0
  21. package/dist/core-compat.js.map +1 -0
  22. package/dist/custom-agents.d.ts +14 -0
  23. package/dist/custom-agents.d.ts.map +1 -0
  24. package/dist/custom-agents.js +106 -0
  25. package/dist/custom-agents.js.map +1 -0
  26. package/dist/file-lock.d.ts +42 -0
  27. package/dist/file-lock.d.ts.map +1 -0
  28. package/dist/file-lock.js +91 -0
  29. package/dist/file-lock.js.map +1 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +751 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/model-resolver.d.ts +19 -0
  35. package/dist/model-resolver.d.ts.map +1 -0
  36. package/dist/model-resolver.js +61 -0
  37. package/dist/model-resolver.js.map +1 -0
  38. package/dist/types.d.ts +96 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +47 -0
  41. package/dist/types.js.map +1 -0
  42. package/dist/widget.d.ts +56 -0
  43. package/dist/widget.d.ts.map +1 -0
  44. package/dist/widget.js +396 -0
  45. package/dist/widget.js.map +1 -0
  46. package/package.json +10 -6
  47. package/src/__tests__/badge-generation.test.ts +0 -315
  48. package/src/__tests__/config.test.ts +0 -240
  49. package/src/__tests__/esc-propagation.test.ts +0 -162
  50. package/src/__tests__/file-lock.test.ts +0 -244
  51. package/src/__tests__/shutdown-stale-ctx.test.ts +0 -185
  52. package/src/__tests__/workflow-integration.test.ts +0 -334
  53. package/src/agent-manager.ts +0 -334
  54. package/src/agent-runner.ts +0 -329
  55. package/src/config.ts +0 -147
  56. package/src/conversation-viewer.ts +0 -299
  57. package/src/custom-agents.ts +0 -118
  58. package/src/file-lock.ts +0 -102
  59. package/src/index.ts +0 -862
  60. package/src/model-resolver.ts +0 -79
  61. package/src/prompts.ts +0 -39
  62. package/src/skills/explore/SKILL.md +0 -32
  63. package/src/skills/work/SKILL.md +0 -40
  64. package/src/types.ts +0 -146
  65. package/src/widget.ts +0 -454
  66. package/tsconfig.json +0 -19
@@ -1,315 +0,0 @@
1
- /**
2
- * Test: Badge Generation Flow
3
- *
4
- * Tests the full badge name generation flow to identify and verify
5
- * fixes for "Generating session name..." getting stuck.
6
- *
7
- * BUG 1 — Tool mismatch:
8
- * Background agent was told to "Call the set_session_name tool" but the tool
9
- * doesn't exist in the agent's session (only builtin tools available).
10
- * FIX: Changed prompt to output title directly, parse in onComplete callback.
11
- *
12
- * BUG 2 — Wrong event bus:
13
- * Cross-module events emitted via pi.events.emit() but listeners used pi.on()
14
- * (extension lifecycle events) — completely different event bus.
15
- * FIX: Changed all cross-module listeners to pi.events.on().
16
- */
17
-
18
- import { describe, it } from "node:test";
19
- import assert from "node:assert/strict";
20
- import { readFileSync, existsSync } from "node:fs";
21
- import { join } from "node:path";
22
-
23
- const ROOT = join(import.meta.dirname, "../../../..");
24
-
25
- // ─── Helpers ────────────────────────────────────────────────────────
26
-
27
- function readSource(relativePath: string): string {
28
- const fullPath = join(ROOT, relativePath);
29
- if (!existsSync(fullPath)) throw new Error(`File not found: ${fullPath}`);
30
- return readFileSync(fullPath, "utf-8");
31
- }
32
-
33
- // ─── Test: Tool availability in spawned agent ──────────────────────
34
-
35
- describe("Badge generation — tool availability", () => {
36
- it("agent-runner uses only builtin tools, NOT extension-registered tools", () => {
37
- const src = readSource("packages/subagents/src/agent-runner.ts");
38
-
39
- const builtinMatch = src.match(
40
- /const BUILTIN_TOOL_NAMES\s*=\s*(\[.*?\])/s,
41
- );
42
- assert.ok(builtinMatch, "BUILTIN_TOOL_NAMES should be defined");
43
-
44
- const builtinTools: string[] = eval(builtinMatch[1]);
45
- assert.deepStrictEqual(builtinTools, [
46
- "read", "bash", "edit", "write", "grep", "find", "ls",
47
- ]);
48
- });
49
-
50
- it("set_session_name is NOT in the agent's tool list", () => {
51
- const src = readSource("packages/subagents/src/agent-runner.ts");
52
- const builtinMatch = src.match(
53
- /const BUILTIN_TOOL_NAMES\s*=\s*(\[.*?\])/s,
54
- );
55
- const tools: string[] = eval(builtinMatch![1]);
56
- assert.ok(!tools.includes("set_session_name"));
57
- });
58
- });
59
-
60
- // ─── Test: Prompt no longer references non-existent tool ───────────
61
-
62
- describe("Badge generation — prompt fix", () => {
63
- it("prompt includes conversation context inline", () => {
64
- const src = readSource("packages/subagents/src/index.ts");
65
-
66
- assert.ok(
67
- src.includes("Conversation:"),
68
- "Prompt should include conversation context inline",
69
- );
70
-
71
- assert.ok(
72
- src.includes("Reply with ONLY the title"),
73
- "Prompt should ask agent to reply with only the title",
74
- );
75
-
76
- assert.ok(
77
- !src.includes("Call the set_session_name tool"),
78
- "Prompt should NOT tell agent to call set_session_name",
79
- );
80
- });
81
- });
82
-
83
- // ─── Test: onComplete extracts name from result ────────────────────
84
-
85
- describe("Badge generation — onComplete callback", () => {
86
- it("onComplete extracts name from agent result and calls pi.setSessionName", () => {
87
- const src = readSource("packages/subagents/src/index.ts");
88
-
89
- assert.ok(
90
- src.includes('record.description === "Generate session name"'),
91
- "Should detect badge generation agents by description",
92
- );
93
-
94
- assert.ok(
95
- src.includes("pi.setSessionName(name)"),
96
- "Should call pi.setSessionName with extracted name",
97
- );
98
- });
99
- });
100
-
101
- // ─── Test: Agent configuration ────────────────────────────────────
102
-
103
- describe("Badge generation — agent configuration", () => {
104
- it("badge generation uses 'name-gen' agent type (not 'explore')", () => {
105
- const src = readSource("packages/subagents/src/index.ts");
106
-
107
- assert.ok(
108
- src.includes('manager.spawn(pi, sessionCtx, "name-gen", prompt'),
109
- "Badge generation should spawn a 'name-gen' agent",
110
- );
111
-
112
- assert.ok(
113
- !src.includes('manager.spawn(pi, sessionCtx, "explore"'),
114
- "Should NOT use 'explore' — that type can be overridden by user custom agents",
115
- );
116
- });
117
-
118
- it("'name-gen' type is defined in BUILTIN_CONFIGS with empty tools", () => {
119
- const src = readSource("packages/subagents/src/types.ts");
120
-
121
- assert.ok(
122
- src.includes('"name-gen"'),
123
- "types.ts should define name-gen agent config",
124
- );
125
-
126
- assert.ok(
127
- src.includes('builtinToolNames: []'),
128
- "name-gen should have empty tool list",
129
- );
130
- });
131
-
132
- it("background agent is isolated (no extensions, no skills, minimal system prompt)", () => {
133
- const src = readSource("packages/subagents/src/index.ts");
134
-
135
- assert.ok(
136
- src.includes("isolated: true"),
137
- "Should spawn with isolated: true to avoid loading extensions/skills",
138
- );
139
- });
140
-
141
- it("background agent maxTurns is 1 (single response)", () => {
142
- const src = readSource("packages/subagents/src/index.ts");
143
-
144
- assert.ok(
145
- src.includes("maxTurns: 1"),
146
- "Badge generation agent should have maxTurns: 1",
147
- );
148
- });
149
- });
150
-
151
- // ─── Test: Cross-module event bus — the critical fix ───────────────
152
-
153
- describe("Badge generation — event bus (CRITICAL FIX)", () => {
154
- it("emitEvent uses pi.events.emit (not pi.on)", () => {
155
- const src = readSource("packages/core/utils.ts");
156
-
157
- assert.ok(
158
- src.includes("pi.events.emit(eventName, payload)"),
159
- "emitEvent should use pi.events.emit()",
160
- );
161
- });
162
-
163
- it("subagents listens via pi.events.on (NOT pi.on)", () => {
164
- const src = readSource("packages/subagents/src/index.ts");
165
-
166
- // Must use pi.events.on for cross-module events
167
- assert.ok(
168
- src.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
169
- "Subagents should listen via pi.events.on",
170
- );
171
-
172
- // Should NOT use pi.on for custom events
173
- const piOnMatch = src.match(/pi\.on\(UNIPI_EVENTS\.BADGE_GENERATE_REQUEST/g);
174
- assert.ok(!piOnMatch, "Should NOT use pi.on() for cross-module events");
175
- });
176
-
177
- it("utility BADGE_GENERATE_REQUEST listener is removed (input handler already shows overlay)", () => {
178
- const src = readSource("packages/utility/src/index.ts");
179
-
180
- // Should NOT have a separate BADGE_GENERATE_REQUEST listener
181
- // The input handler already shows the overlay and emits the event
182
- assert.ok(
183
- !src.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
184
- "Utility should NOT have a separate BADGE_GENERATE_REQUEST listener",
185
- );
186
- });
187
-
188
- it("workflow listens for MODULE_READY via pi.events.on (NOT pi.on)", () => {
189
- const src = readSource("packages/workflow/index.ts");
190
-
191
- assert.ok(
192
- src.includes("pi.events.on(UNIPI_EVENTS.MODULE_READY"),
193
- "Workflow should listen via pi.events.on",
194
- );
195
-
196
- const piOnMatch = src.match(/pi\.on\(UNIPI_EVENTS\.MODULE_READY/g);
197
- assert.ok(!piOnMatch, "Should NOT use pi.on() for cross-module events");
198
- });
199
-
200
- it("pi.on() is ONLY used for known lifecycle events", () => {
201
- const subagentsSrc = readSource("packages/subagents/src/index.ts");
202
- const utilitySrc = readSource("packages/utility/src/index.ts");
203
-
204
- // These are valid lifecycle events that should use pi.on()
205
- const validLifecycleEvents = [
206
- "session_start", "session_shutdown", "input",
207
- "tool_call", "tool_execution_start",
208
- "agent_end", "before_agent_start",
209
- ];
210
-
211
- // Check that pi.on() is only used with lifecycle events
212
- const piOnPattern = /pi\.on\("([^"]+)"/g;
213
- let match;
214
- while ((match = piOnPattern.exec(subagentsSrc)) !== null) {
215
- assert.ok(
216
- validLifecycleEvents.includes(match[1]),
217
- `subagents: pi.on("${match[1]}") should be a lifecycle event, use pi.events.on() for custom events`,
218
- );
219
- }
220
- while ((match = piOnPattern.exec(utilitySrc)) !== null) {
221
- assert.ok(
222
- validLifecycleEvents.includes(match[1]),
223
- `utility: pi.on("${match[1]}") should be a lifecycle event`,
224
- );
225
- }
226
- });
227
- });
228
-
229
- // ─── Test: Event flow ──────────────────────────────────────────────
230
-
231
- describe("Badge generation — event flow", () => {
232
- it("utility emits BADGE_GENERATE_REQUEST after agent responds (deferred from input)", () => {
233
- const src = readSource("packages/utility/src/index.ts");
234
-
235
- // BADGE_GENERATE_REQUEST should be emitted in agent_end handler, not input
236
- assert.ok(src.includes("BADGE_GENERATE_REQUEST"));
237
- assert.ok(src.includes('source: "input-hook"'));
238
-
239
- // input handler should NOT emit BADGE_GENERATE_REQUEST directly
240
- const inputBlock = src.match(/pi\.on\("input"[\s\S]*?(?=pi\.on\(|$)/)?.[0] ?? "";
241
- assert.ok(
242
- !inputBlock.includes("BADGE_GENERATE_REQUEST"),
243
- "input handler should NOT emit BADGE_GENERATE_REQUEST — deferred to agent_end",
244
- );
245
-
246
- // agent_end handler should emit BADGE_GENERATE_REQUEST
247
- const agentEndBlock = src.match(/pi\.on\("agent_end"[\s\S]*?(?=pi\.on\(|$)/)?.[0] ?? "";
248
- assert.ok(
249
- agentEndBlock.includes("BADGE_GENERATE_REQUEST"),
250
- "agent_end handler should emit BADGE_GENERATE_REQUEST with full conversation context",
251
- );
252
- });
253
-
254
- it("BADGE_GENERATE_REQUEST event is defined in core", () => {
255
- const src = readSource("packages/core/events.ts");
256
- assert.ok(src.includes("BADGE_GENERATE_REQUEST"));
257
- });
258
- });
259
-
260
- // ─── Test: Model resolution ────────────────────────────────────────
261
-
262
- describe("Badge generation — model resolution", () => {
263
- it("reads generationModel from badge.json instead of hardcoding", () => {
264
- const src = readSource("packages/subagents/src/index.ts");
265
-
266
- assert.ok(!src.includes('"openai/gpt-oss-20b"'));
267
- assert.ok(src.includes(".unipi/config/badge.json"));
268
- assert.ok(src.includes("parsed.generationModel"));
269
- });
270
- });
271
-
272
- // ─── Summary ───────────────────────────────────────────────────────
273
-
274
- describe("Badge generation — ROOT CAUSE SUMMARY", () => {
275
- it("BUG 1 FIXED: prompt no longer references non-existent tool", () => {
276
- const src = readSource("packages/subagents/src/index.ts");
277
-
278
- assert.ok(!src.includes("Call the set_session_name tool"),
279
- "FIXED: prompt no longer tells agent to call set_session_name");
280
- assert.ok(src.includes("Reply with ONLY the title"),
281
- "FIXED: prompt asks agent to reply with only the title");
282
- });
283
-
284
- it("BUG 1 FIXED: onComplete extracts name and sets it directly", () => {
285
- const src = readSource("packages/subagents/src/index.ts");
286
-
287
- assert.ok(src.includes('record.description === "Generate session name"'),
288
- "FIXED: onComplete detects badge generation agents");
289
- assert.ok(src.includes("pi.setSessionName(name)"),
290
- "FIXED: onComplete calls pi.setSessionName directly");
291
- });
292
-
293
- it("BUG 2 FIXED: cross-module events use pi.events.on, not pi.on", () => {
294
- const subagentsSrc = readSource("packages/subagents/src/index.ts");
295
- const utilitySrc = readSource("packages/utility/src/index.ts");
296
- const workflowSrc = readSource("packages/workflow/index.ts");
297
-
298
- // Subagents: correct event bus
299
- assert.ok(
300
- subagentsSrc.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
301
- "subagents: must use pi.events.on for BADGE_GENERATE_REQUEST",
302
- );
303
-
304
- // Utility: no duplicate listener (input handler already handles it)
305
- assert.ok(
306
- !utilitySrc.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
307
- "utility: no duplicate BADGE_GENERATE_REQUEST listener", );
308
-
309
- // Workflow: correct event bus
310
- assert.ok(
311
- workflowSrc.includes("pi.events.on(UNIPI_EVENTS.MODULE_READY"),
312
- "workflow: must use pi.events.on for MODULE_READY",
313
- );
314
- });
315
- });
@@ -1,240 +0,0 @@
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
- });