opencode-swarm-plugin 0.36.1 → 0.38.0

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Swarm Decompose Unit Tests
3
+ *
4
+ * Tests for task decomposition, validation, and eval capture integration.
5
+ *
6
+ * TDD: Testing eval capture integration - verifies captureDecomposition() is called
7
+ * after successful validation with correct parameters.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test";
10
+ import * as fs from "node:fs";
11
+ import { swarm_validate_decomposition } from "./swarm-decompose";
12
+ import * as evalCapture from "./eval-capture.js";
13
+
14
+ // ============================================================================
15
+ // Test Setup
16
+ // ============================================================================
17
+
18
+ const mockContext = {
19
+ sessionID: `test-decompose-${Date.now()}`,
20
+ messageID: `test-message-${Date.now()}`,
21
+ agent: "test-agent",
22
+ abort: new AbortController().signal,
23
+ };
24
+
25
+ let testProjectPath: string;
26
+
27
+ beforeEach(() => {
28
+ testProjectPath = `/tmp/test-swarm-decompose-${Date.now()}`;
29
+ fs.mkdirSync(testProjectPath, { recursive: true });
30
+ });
31
+
32
+ afterEach(() => {
33
+ if (fs.existsSync(testProjectPath)) {
34
+ fs.rmSync(testProjectPath, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ // ============================================================================
39
+ // Eval Capture Integration Tests
40
+ // ============================================================================
41
+
42
+ describe("captureDecomposition integration", () => {
43
+ test("calls captureDecomposition after successful validation with all params", async () => {
44
+ // Spy on captureDecomposition
45
+ const captureDecompositionSpy = spyOn(evalCapture, "captureDecomposition");
46
+
47
+ const validCellTree = JSON.stringify({
48
+ epic: {
49
+ title: "Add OAuth",
50
+ description: "Implement OAuth authentication",
51
+ },
52
+ subtasks: [
53
+ {
54
+ title: "Add OAuth provider config",
55
+ description: "Set up Google OAuth",
56
+ files: ["src/auth/google.ts", "src/auth/config.ts"],
57
+ dependencies: [],
58
+ estimated_complexity: 2,
59
+ },
60
+ {
61
+ title: "Add login UI",
62
+ description: "Create login button component",
63
+ files: ["src/components/LoginButton.tsx"],
64
+ dependencies: [0],
65
+ estimated_complexity: 1,
66
+ },
67
+ ],
68
+ });
69
+
70
+ const result = await swarm_validate_decomposition.execute(
71
+ {
72
+ response: validCellTree,
73
+ project_path: testProjectPath,
74
+ task: "Add user authentication",
75
+ context: "Using NextAuth.js",
76
+ strategy: "feature-based" as const,
77
+ epic_id: "test-epic-123",
78
+ },
79
+ mockContext,
80
+ );
81
+
82
+ const parsed = JSON.parse(result);
83
+ expect(parsed.valid).toBe(true);
84
+
85
+ // Verify captureDecomposition was called with correct params
86
+ expect(captureDecompositionSpy).toHaveBeenCalledTimes(1);
87
+ expect(captureDecompositionSpy).toHaveBeenCalledWith({
88
+ epicId: "test-epic-123",
89
+ projectPath: testProjectPath,
90
+ task: "Add user authentication",
91
+ context: "Using NextAuth.js",
92
+ strategy: "feature-based",
93
+ epicTitle: "Add OAuth",
94
+ epicDescription: "Implement OAuth authentication",
95
+ subtasks: [
96
+ {
97
+ title: "Add OAuth provider config",
98
+ description: "Set up Google OAuth",
99
+ files: ["src/auth/google.ts", "src/auth/config.ts"],
100
+ dependencies: [],
101
+ estimated_complexity: 2,
102
+ },
103
+ {
104
+ title: "Add login UI",
105
+ description: "Create login button component",
106
+ files: ["src/components/LoginButton.tsx"],
107
+ dependencies: [0],
108
+ estimated_complexity: 1,
109
+ },
110
+ ],
111
+ });
112
+
113
+ captureDecompositionSpy.mockRestore();
114
+ });
115
+
116
+ test("does not call captureDecomposition when validation fails", async () => {
117
+ const captureDecompositionSpy = spyOn(evalCapture, "captureDecomposition");
118
+
119
+ // Invalid CellTree - missing required fields
120
+ const invalidCellTree = JSON.stringify({
121
+ epic: { title: "Missing subtasks" },
122
+ // No subtasks array
123
+ });
124
+
125
+ const result = await swarm_validate_decomposition.execute(
126
+ {
127
+ response: invalidCellTree,
128
+ project_path: testProjectPath,
129
+ task: "Add auth",
130
+ strategy: "auto" as const,
131
+ epic_id: "test-epic-456",
132
+ },
133
+ mockContext,
134
+ );
135
+
136
+ const parsed = JSON.parse(result);
137
+ expect(parsed.valid).toBe(false);
138
+
139
+ // Verify captureDecomposition was NOT called
140
+ expect(captureDecompositionSpy).not.toHaveBeenCalled();
141
+
142
+ captureDecompositionSpy.mockRestore();
143
+ });
144
+
145
+ test("handles optional context and description fields", async () => {
146
+ const captureDecompositionSpy = spyOn(evalCapture, "captureDecomposition");
147
+
148
+ const validCellTree = JSON.stringify({
149
+ epic: {
150
+ title: "Fix bug",
151
+ // No description
152
+ },
153
+ subtasks: [
154
+ {
155
+ title: "Add test",
156
+ files: ["src/test.ts"],
157
+ dependencies: [],
158
+ estimated_complexity: 1,
159
+ },
160
+ ],
161
+ });
162
+
163
+ const result = await swarm_validate_decomposition.execute(
164
+ {
165
+ response: validCellTree,
166
+ project_path: testProjectPath,
167
+ task: "Fix the auth bug",
168
+ // No context
169
+ strategy: "risk-based" as const,
170
+ epic_id: "test-epic-789",
171
+ },
172
+ mockContext,
173
+ );
174
+
175
+ const parsed = JSON.parse(result);
176
+ expect(parsed.valid).toBe(true);
177
+
178
+ // Verify captureDecomposition was called without optional fields
179
+ expect(captureDecompositionSpy).toHaveBeenCalledTimes(1);
180
+ const call = captureDecompositionSpy.mock.calls[0];
181
+ expect(call[0].epicId).toBe("test-epic-789");
182
+ expect(call[0].context).toBeUndefined();
183
+ // Schema default makes description empty string instead of undefined
184
+ expect(call[0].epicDescription).toBe("");
185
+
186
+ captureDecompositionSpy.mockRestore();
187
+ });
188
+ });
@@ -535,11 +535,31 @@ export const swarm_decompose = tool({
535
535
  * Use this after the agent responds to swarm:decompose to validate the structure.
536
536
  */
537
537
  export const swarm_validate_decomposition = tool({
538
- description: "Validate a decomposition response against CellTreeSchema",
538
+ description: "Validate a decomposition response against CellTreeSchema and capture for eval",
539
539
  args: {
540
540
  response: tool.schema
541
541
  .string()
542
542
  .describe("JSON response from agent (CellTree format)"),
543
+ project_path: tool.schema
544
+ .string()
545
+ .optional()
546
+ .describe("Project path for eval capture"),
547
+ task: tool.schema
548
+ .string()
549
+ .optional()
550
+ .describe("Original task description for eval capture"),
551
+ context: tool.schema
552
+ .string()
553
+ .optional()
554
+ .describe("Context provided for decomposition"),
555
+ strategy: tool.schema
556
+ .enum(["file-based", "feature-based", "risk-based", "auto"])
557
+ .optional()
558
+ .describe("Decomposition strategy used"),
559
+ epic_id: tool.schema
560
+ .string()
561
+ .optional()
562
+ .describe("Epic ID for eval capture"),
543
563
  },
544
564
  async execute(args) {
545
565
  try {
@@ -597,6 +617,37 @@ export const swarm_validate_decomposition = tool({
597
617
  validated.subtasks,
598
618
  );
599
619
 
620
+ // Capture decomposition for eval if all required params provided
621
+ if (
622
+ args.project_path &&
623
+ args.task &&
624
+ args.strategy &&
625
+ args.epic_id
626
+ ) {
627
+ try {
628
+ const { captureDecomposition } = await import("./eval-capture.js");
629
+ captureDecomposition({
630
+ epicId: args.epic_id,
631
+ projectPath: args.project_path,
632
+ task: args.task,
633
+ context: args.context,
634
+ strategy: args.strategy,
635
+ epicTitle: validated.epic.title,
636
+ epicDescription: validated.epic.description,
637
+ subtasks: validated.subtasks.map((s) => ({
638
+ title: s.title,
639
+ description: s.description,
640
+ files: s.files,
641
+ dependencies: s.dependencies,
642
+ estimated_complexity: s.estimated_complexity,
643
+ })),
644
+ });
645
+ } catch (error) {
646
+ // Non-fatal - don't block validation if capture fails
647
+ console.warn("[swarm_validate_decomposition] Failed to capture decomposition:", error);
648
+ }
649
+ }
650
+
600
651
  return JSON.stringify(
601
652
  {
602
653
  valid: true,
@@ -6,10 +6,13 @@
6
6
  * - Researcher spawning for identified technologies
7
7
  * - Summary collection from semantic-memory
8
8
  * - Research result aggregation
9
+ * - Eval capture integration (captureSubtaskOutcome wiring)
9
10
  */
10
11
 
11
- import { describe, test, expect, beforeEach } from "bun:test";
12
- import { runResearchPhase, extractTechStack } from "./swarm-orchestrate";
12
+ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
13
+ import { runResearchPhase, extractTechStack, swarm_complete } from "./swarm-orchestrate";
14
+ import * as evalCapture from "./eval-capture.js";
15
+ import * as fs from "node:fs";
13
16
 
14
17
  describe("extractTechStack", () => {
15
18
  test("extracts Next.js from task description", () => {
@@ -115,9 +118,269 @@ describe("runResearchPhase", () => {
115
118
  });
116
119
  });
117
120
 
118
- describe("swarm_research_phase tool", () => {
119
- test.todo("exposes research phase as plugin tool");
120
- test.todo("validates task parameter");
121
- test.todo("validates project_path parameter");
122
- test.todo("returns JSON string with research results");
121
+ // describe("swarm_research_phase tool", () => {
122
+ // test.todo("exposes research phase as plugin tool");
123
+ // test.todo("validates task parameter");
124
+ // test.todo("validates project_path parameter");
125
+ // test.todo("returns JSON string with research results");
126
+ // });
127
+
128
+ // ============================================================================
129
+ // Eval Capture Integration Tests (swarm_complete)
130
+ // ============================================================================
131
+
132
+ describe("captureSubtaskOutcome integration", () => {
133
+ const mockContext = {
134
+ sessionID: `test-complete-${Date.now()}`,
135
+ messageID: `test-message-${Date.now()}`,
136
+ agent: "test-agent",
137
+ abort: new AbortController().signal,
138
+ };
139
+
140
+ let testProjectPath: string;
141
+
142
+ beforeEach(async () => {
143
+ testProjectPath = `/tmp/test-swarm-complete-${Date.now()}`;
144
+ fs.mkdirSync(testProjectPath, { recursive: true });
145
+
146
+ // Create .hive directory and issues.jsonl
147
+ const hiveDir = `${testProjectPath}/.hive`;
148
+ fs.mkdirSync(hiveDir, { recursive: true });
149
+ fs.writeFileSync(`${hiveDir}/issues.jsonl`, "", "utf-8");
150
+
151
+ // Set hive working directory to testProjectPath
152
+ const { setHiveWorkingDirectory } = await import("./hive");
153
+ setHiveWorkingDirectory(testProjectPath);
154
+ });
155
+
156
+ afterEach(() => {
157
+ if (fs.existsSync(testProjectPath)) {
158
+ fs.rmSync(testProjectPath, { recursive: true, force: true });
159
+ }
160
+ });
161
+
162
+ test("calls captureSubtaskOutcome after successful completion with all params", async () => {
163
+ // Import hive tools
164
+ const { hive_create_epic } = await import("./hive");
165
+
166
+ // Spy on captureSubtaskOutcome
167
+ const captureOutcomeSpy = spyOn(evalCapture, "captureSubtaskOutcome");
168
+
169
+ // Create an epic with a subtask using hive_create_epic
170
+ const epicResult = await hive_create_epic.execute({
171
+ epic_title: "Add OAuth",
172
+ epic_description: "Implement OAuth authentication",
173
+ subtasks: [
174
+ {
175
+ title: "Add auth service",
176
+ priority: 2,
177
+ files: ["src/auth/service.ts", "src/auth/schema.ts"],
178
+ },
179
+ ],
180
+ }, mockContext);
181
+
182
+ const epicData = JSON.parse(epicResult);
183
+ expect(epicData.success).toBe(true);
184
+
185
+ const epicId = epicData.epic.id;
186
+ const beadId = epicData.subtasks[0].id;
187
+
188
+ const startTime = Date.now() - 120000; // Started 2 minutes ago
189
+ const plannedFiles = ["src/auth/service.ts", "src/auth/schema.ts"];
190
+ const actualFiles = ["src/auth/service.ts", "src/auth/schema.ts", "src/auth/types.ts"];
191
+
192
+ // Call swarm_complete
193
+ const result = await swarm_complete.execute(
194
+ {
195
+ project_key: testProjectPath,
196
+ agent_name: "TestAgent",
197
+ bead_id: beadId,
198
+ summary: "Implemented OAuth service with JWT strategy",
199
+ files_touched: actualFiles,
200
+ skip_verification: true, // Skip verification for test
201
+ skip_review: true, // Skip review for test
202
+ planned_files: plannedFiles,
203
+ start_time: startTime,
204
+ error_count: 0,
205
+ retry_count: 0,
206
+ },
207
+ mockContext,
208
+ );
209
+
210
+ const parsed = JSON.parse(result);
211
+ expect(parsed.success).toBe(true);
212
+
213
+ // Verify captureSubtaskOutcome was called with correct params
214
+ expect(captureOutcomeSpy).toHaveBeenCalledTimes(1);
215
+
216
+ const call = captureOutcomeSpy.mock.calls[0][0];
217
+ expect(call.epicId).toBe(epicId);
218
+ expect(call.projectPath).toBe(testProjectPath);
219
+ expect(call.beadId).toBe(beadId);
220
+ expect(call.title).toBe("Add auth service");
221
+ expect(call.plannedFiles).toEqual(plannedFiles);
222
+ expect(call.actualFiles).toEqual(actualFiles);
223
+ expect(call.durationMs).toBeGreaterThan(0);
224
+ expect(call.errorCount).toBe(0);
225
+ expect(call.retryCount).toBe(0);
226
+ expect(call.success).toBe(true);
227
+
228
+ captureOutcomeSpy.mockRestore();
229
+ });
230
+
231
+ test("does not call captureSubtaskOutcome when required params missing", async () => {
232
+ const { hive_create_epic } = await import("./hive");
233
+ const captureOutcomeSpy = spyOn(evalCapture, "captureSubtaskOutcome");
234
+
235
+ // Create an epic with a subtask
236
+ const epicResult = await hive_create_epic.execute({
237
+ epic_title: "Fix bug",
238
+ subtasks: [
239
+ {
240
+ title: "Fix auth bug",
241
+ priority: 1,
242
+ files: ["src/auth.ts"],
243
+ },
244
+ ],
245
+ }, mockContext);
246
+
247
+ const epicData = JSON.parse(epicResult);
248
+ const beadId = epicData.subtasks[0].id;
249
+
250
+ // Call without planned_files or start_time
251
+ const result = await swarm_complete.execute(
252
+ {
253
+ project_key: testProjectPath,
254
+ agent_name: "TestAgent",
255
+ bead_id: beadId,
256
+ summary: "Fixed the bug",
257
+ skip_verification: true,
258
+ skip_review: true,
259
+ // No planned_files, start_time
260
+ },
261
+ mockContext,
262
+ );
263
+
264
+ const parsed = JSON.parse(result);
265
+ expect(parsed.success).toBe(true);
266
+
267
+ // Capture should still be called, but with default values
268
+ // (The function is called in all success cases, it just handles missing params)
269
+ expect(captureOutcomeSpy).toHaveBeenCalledTimes(1);
270
+
271
+ captureOutcomeSpy.mockRestore();
272
+ });
273
+ });
274
+
275
+ // ============================================================================
276
+ // Eval Capture Integration Tests (swarm_record_outcome)
277
+ // ============================================================================
278
+
279
+ describe("finalizeEvalRecord integration", () => {
280
+ const mockContext = {
281
+ sessionID: `test-finalize-${Date.now()}`,
282
+ messageID: `test-message-${Date.now()}`,
283
+ agent: "test-agent",
284
+ abort: new AbortController().signal,
285
+ };
286
+
287
+ test("calls finalizeEvalRecord when project_path and epic_id provided", async () => {
288
+ const { swarm_record_outcome } = await import("./swarm-orchestrate");
289
+
290
+ // Spy on finalizeEvalRecord
291
+ const finalizeEvalSpy = spyOn(evalCapture, "finalizeEvalRecord");
292
+ finalizeEvalSpy.mockReturnValue(null); // Mock return value
293
+
294
+ const testProjectPath = "/tmp/test-project";
295
+ const testEpicId = "bd-test123";
296
+ const testBeadId = `${testEpicId}.0`;
297
+
298
+ // Call swarm_record_outcome with epic_id and project_path
299
+ await swarm_record_outcome.execute({
300
+ bead_id: testBeadId,
301
+ duration_ms: 120000,
302
+ error_count: 0,
303
+ retry_count: 0,
304
+ success: true,
305
+ files_touched: ["src/test.ts"],
306
+ epic_id: testEpicId,
307
+ project_path: testProjectPath,
308
+ }, mockContext);
309
+
310
+ // Verify finalizeEvalRecord was called
311
+ expect(finalizeEvalSpy).toHaveBeenCalledTimes(1);
312
+ expect(finalizeEvalSpy).toHaveBeenCalledWith({
313
+ epicId: testEpicId,
314
+ projectPath: testProjectPath,
315
+ });
316
+
317
+ finalizeEvalSpy.mockRestore();
318
+ });
319
+
320
+ test("does not call finalizeEvalRecord when epic_id or project_path missing", async () => {
321
+ const { swarm_record_outcome } = await import("./swarm-orchestrate");
322
+
323
+ // Spy on finalizeEvalRecord
324
+ const finalizeEvalSpy = spyOn(evalCapture, "finalizeEvalRecord");
325
+
326
+ const testBeadId = "bd-test123.0";
327
+
328
+ // Call without epic_id or project_path
329
+ await swarm_record_outcome.execute({
330
+ bead_id: testBeadId,
331
+ duration_ms: 120000,
332
+ error_count: 0,
333
+ retry_count: 0,
334
+ success: true,
335
+ }, mockContext);
336
+
337
+ // Verify finalizeEvalRecord was NOT called
338
+ expect(finalizeEvalSpy).toHaveBeenCalledTimes(0);
339
+
340
+ finalizeEvalSpy.mockRestore();
341
+ });
342
+
343
+ test("includes finalized record in response when available", async () => {
344
+ const { swarm_record_outcome } = await import("./swarm-orchestrate");
345
+
346
+ // Mock finalizeEvalRecord to return a record
347
+ const mockFinalRecord = {
348
+ id: "bd-test123",
349
+ timestamp: new Date().toISOString(),
350
+ project_path: "/tmp/test-project",
351
+ task: "Test task",
352
+ strategy: "file-based" as const,
353
+ subtask_count: 2,
354
+ epic_title: "Test Epic",
355
+ subtasks: [],
356
+ overall_success: true,
357
+ total_duration_ms: 240000,
358
+ total_errors: 0,
359
+ };
360
+
361
+ const finalizeEvalSpy = spyOn(evalCapture, "finalizeEvalRecord");
362
+ finalizeEvalSpy.mockReturnValue(mockFinalRecord);
363
+
364
+ const testProjectPath = "/tmp/test-project";
365
+ const testEpicId = "bd-test123";
366
+ const testBeadId = `${testEpicId}.0`;
367
+
368
+ // Call with epic_id and project_path
369
+ const result = await swarm_record_outcome.execute({
370
+ bead_id: testBeadId,
371
+ duration_ms: 120000,
372
+ error_count: 0,
373
+ retry_count: 0,
374
+ success: true,
375
+ epic_id: testEpicId,
376
+ project_path: testProjectPath,
377
+ }, mockContext);
378
+
379
+ // Parse result and check for finalized record
380
+ const parsed = JSON.parse(result);
381
+ expect(parsed).toHaveProperty("finalized_eval_record");
382
+ expect(parsed.finalized_eval_record).toEqual(mockFinalRecord);
383
+
384
+ finalizeEvalSpy.mockRestore();
385
+ });
123
386
  });