stagent 0.1.0 → 0.1.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 (64) hide show
  1. package/README.md +33 -30
  2. package/dist/cli.js +376 -49
  3. package/package.json +20 -21
  4. package/public/desktop-icon-512.png +0 -0
  5. package/public/icon-512.png +0 -0
  6. package/src/app/api/data/clear/route.ts +0 -7
  7. package/src/app/api/data/seed/route.ts +0 -7
  8. package/src/app/api/profiles/[id]/context/route.ts +109 -0
  9. package/src/components/dashboard/__tests__/accessibility.test.tsx +42 -0
  10. package/src/components/documents/__tests__/document-upload-dialog.test.tsx +46 -0
  11. package/src/components/notifications/__tests__/pending-approval-host.test.tsx +122 -0
  12. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +79 -0
  13. package/src/components/notifications/pending-approval-host.tsx +49 -25
  14. package/src/components/profiles/context-proposal-review.tsx +145 -0
  15. package/src/components/profiles/learned-context-panel.tsx +286 -0
  16. package/src/components/profiles/profile-detail-view.tsx +4 -0
  17. package/src/components/projects/__tests__/dialog-focus.test.tsx +87 -0
  18. package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +59 -0
  19. package/src/lib/__tests__/setup-verify.test.ts +28 -0
  20. package/src/lib/__tests__/utils.test.ts +29 -0
  21. package/src/lib/agents/__tests__/claude-agent.test.ts +946 -0
  22. package/src/lib/agents/__tests__/execution-manager.test.ts +63 -0
  23. package/src/lib/agents/__tests__/router.test.ts +61 -0
  24. package/src/lib/agents/claude-agent.ts +34 -5
  25. package/src/lib/agents/learned-context.ts +322 -0
  26. package/src/lib/agents/pattern-extractor.ts +150 -0
  27. package/src/lib/agents/profiles/__tests__/compatibility.test.ts +76 -0
  28. package/src/lib/agents/profiles/__tests__/registry.test.ts +177 -0
  29. package/src/lib/agents/profiles/builtins/sweep/SKILL.md +47 -0
  30. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +12 -0
  31. package/src/lib/agents/runtime/__tests__/catalog.test.ts +38 -0
  32. package/src/lib/agents/runtime/openai-codex.ts +1 -1
  33. package/src/lib/agents/sweep.ts +65 -0
  34. package/src/lib/constants/__tests__/task-status.test.ts +119 -0
  35. package/src/lib/data/seed-data/__tests__/profiles.test.ts +141 -0
  36. package/src/lib/db/__tests__/bootstrap.test.ts +56 -0
  37. package/src/lib/db/bootstrap.ts +301 -0
  38. package/src/lib/db/index.ts +2 -205
  39. package/src/lib/db/migrations/0004_add_documents.sql +2 -1
  40. package/src/lib/db/migrations/0005_add_document_preprocessing.sql +2 -0
  41. package/src/lib/db/migrations/0006_add_agent_profile.sql +1 -0
  42. package/src/lib/db/migrations/0007_add_usage_metering_ledger.sql +9 -2
  43. package/src/lib/db/migrations/meta/_journal.json +43 -1
  44. package/src/lib/db/schema.ts +34 -0
  45. package/src/lib/desktop/__tests__/sidecar-launch.test.ts +70 -0
  46. package/src/lib/desktop/sidecar-launch.ts +85 -0
  47. package/src/lib/documents/__tests__/context-builder.test.ts +57 -0
  48. package/src/lib/documents/__tests__/output-scanner.test.ts +141 -0
  49. package/src/lib/notifications/actionable.ts +21 -7
  50. package/src/lib/settings/__tests__/auth.test.ts +220 -0
  51. package/src/lib/settings/__tests__/budget-guardrails.test.ts +181 -0
  52. package/src/lib/tauri-bridge.ts +138 -0
  53. package/src/lib/usage/__tests__/ledger.test.ts +284 -0
  54. package/src/lib/utils/__tests__/crypto.test.ts +90 -0
  55. package/src/lib/validators/__tests__/profile.test.ts +119 -0
  56. package/src/lib/validators/__tests__/project.test.ts +82 -0
  57. package/src/lib/validators/__tests__/settings.test.ts +151 -0
  58. package/src/lib/validators/__tests__/task.test.ts +144 -0
  59. package/src/lib/workflows/__tests__/definition-validation.test.ts +164 -0
  60. package/src/lib/workflows/__tests__/engine.test.ts +114 -0
  61. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  62. package/src/lib/workflows/__tests__/parallel.test.ts +75 -0
  63. package/src/lib/workflows/__tests__/swarm.test.ts +97 -0
  64. package/src/test/setup.ts +10 -0
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ProfileConfigSchema } from "@/lib/validators/profile";
3
+
4
+ describe("ProfileConfigSchema", () => {
5
+ const validProfile = {
6
+ id: "test-profile",
7
+ name: "Test Profile",
8
+ version: "1.0.0",
9
+ domain: "work",
10
+ tags: ["test", "example"],
11
+ };
12
+
13
+ it("accepts minimal valid profile", () => {
14
+ const result = ProfileConfigSchema.safeParse(validProfile);
15
+ expect(result.success).toBe(true);
16
+ });
17
+
18
+ it("accepts full valid profile", () => {
19
+ const result = ProfileConfigSchema.safeParse({
20
+ ...validProfile,
21
+ allowedTools: ["Read", "Grep"],
22
+ mcpServers: { myServer: { url: "http://localhost:3000" } },
23
+ canUseToolPolicy: {
24
+ autoApprove: ["Read"],
25
+ autoDeny: ["Bash"],
26
+ },
27
+ hooks: {
28
+ preToolCall: ["echo pre"],
29
+ postToolCall: ["echo post"],
30
+ },
31
+ temperature: 0.5,
32
+ maxTurns: 20,
33
+ outputFormat: "markdown",
34
+ author: "stagent",
35
+ source: "https://github.com/stagent/profiles",
36
+ tests: [
37
+ {
38
+ task: "Do a thing",
39
+ expectedKeywords: ["thing", "done"],
40
+ },
41
+ ],
42
+ supportedRuntimes: ["claude-code", "openai-codex-app-server"],
43
+ runtimeOverrides: {
44
+ "openai-codex-app-server": {
45
+ instructions: "Use the Codex-specific prompt",
46
+ allowedTools: ["Read"],
47
+ },
48
+ },
49
+ });
50
+ expect(result.success).toBe(true);
51
+ });
52
+
53
+ it("rejects missing id", () => {
54
+ const { id, ...noId } = validProfile;
55
+ const result = ProfileConfigSchema.safeParse(noId);
56
+ expect(result.success).toBe(false);
57
+ });
58
+
59
+ it("rejects empty id", () => {
60
+ const result = ProfileConfigSchema.safeParse({ ...validProfile, id: "" });
61
+ expect(result.success).toBe(false);
62
+ });
63
+
64
+ it("rejects invalid version format", () => {
65
+ const result = ProfileConfigSchema.safeParse({
66
+ ...validProfile,
67
+ version: "1.0",
68
+ });
69
+ expect(result.success).toBe(false);
70
+ });
71
+
72
+ it("rejects invalid domain", () => {
73
+ const result = ProfileConfigSchema.safeParse({
74
+ ...validProfile,
75
+ domain: "other",
76
+ });
77
+ expect(result.success).toBe(false);
78
+ });
79
+
80
+ it("rejects temperature out of range", () => {
81
+ const tooHigh = ProfileConfigSchema.safeParse({
82
+ ...validProfile,
83
+ temperature: 1.5,
84
+ });
85
+ expect(tooHigh.success).toBe(false);
86
+
87
+ const tooLow = ProfileConfigSchema.safeParse({
88
+ ...validProfile,
89
+ temperature: -0.1,
90
+ });
91
+ expect(tooLow.success).toBe(false);
92
+ });
93
+
94
+ it("rejects invalid source URL", () => {
95
+ const result = ProfileConfigSchema.safeParse({
96
+ ...validProfile,
97
+ source: "not-a-url",
98
+ });
99
+ expect(result.success).toBe(false);
100
+ });
101
+
102
+ it("accepts valid domain values", () => {
103
+ for (const domain of ["work", "personal"]) {
104
+ const result = ProfileConfigSchema.safeParse({
105
+ ...validProfile,
106
+ domain,
107
+ });
108
+ expect(result.success).toBe(true);
109
+ }
110
+ });
111
+
112
+ it("rejects negative maxTurns", () => {
113
+ const result = ProfileConfigSchema.safeParse({
114
+ ...validProfile,
115
+ maxTurns: -5,
116
+ });
117
+ expect(result.success).toBe(false);
118
+ });
119
+ });
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createProjectSchema, updateProjectSchema } from "@/lib/validators/project";
3
+
4
+ describe("createProjectSchema", () => {
5
+ it("accepts valid input with name only", () => {
6
+ const result = createProjectSchema.safeParse({ name: "My Project" });
7
+ expect(result.success).toBe(true);
8
+ });
9
+
10
+ it("accepts valid input with name and description", () => {
11
+ const result = createProjectSchema.safeParse({
12
+ name: "My Project",
13
+ description: "A great project",
14
+ });
15
+ expect(result.success).toBe(true);
16
+ });
17
+
18
+ it("rejects empty name", () => {
19
+ const result = createProjectSchema.safeParse({ name: "" });
20
+ expect(result.success).toBe(false);
21
+ });
22
+
23
+ it("rejects missing name", () => {
24
+ const result = createProjectSchema.safeParse({});
25
+ expect(result.success).toBe(false);
26
+ });
27
+
28
+ it("rejects name exceeding 100 characters", () => {
29
+ const result = createProjectSchema.safeParse({ name: "a".repeat(101) });
30
+ expect(result.success).toBe(false);
31
+ });
32
+
33
+ it("accepts name at max length (100)", () => {
34
+ const result = createProjectSchema.safeParse({ name: "a".repeat(100) });
35
+ expect(result.success).toBe(true);
36
+ });
37
+
38
+ it("rejects description exceeding 500 characters", () => {
39
+ const result = createProjectSchema.safeParse({
40
+ name: "Test",
41
+ description: "a".repeat(501),
42
+ });
43
+ expect(result.success).toBe(false);
44
+ });
45
+
46
+ it("accepts description at max length (500)", () => {
47
+ const result = createProjectSchema.safeParse({
48
+ name: "Test",
49
+ description: "a".repeat(500),
50
+ });
51
+ expect(result.success).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("updateProjectSchema", () => {
56
+ it("accepts empty object (all fields optional)", () => {
57
+ const result = updateProjectSchema.safeParse({});
58
+ expect(result.success).toBe(true);
59
+ });
60
+
61
+ it("accepts valid status values", () => {
62
+ for (const status of ["active", "paused", "completed"]) {
63
+ const result = updateProjectSchema.safeParse({ status });
64
+ expect(result.success).toBe(true);
65
+ }
66
+ });
67
+
68
+ it("rejects invalid status", () => {
69
+ const result = updateProjectSchema.safeParse({ status: "archived" });
70
+ expect(result.success).toBe(false);
71
+ });
72
+
73
+ it("rejects empty name when provided", () => {
74
+ const result = updateProjectSchema.safeParse({ name: "" });
75
+ expect(result.success).toBe(false);
76
+ });
77
+
78
+ it("accepts partial updates", () => {
79
+ const result = updateProjectSchema.safeParse({ description: "Updated" });
80
+ expect(result.success).toBe(true);
81
+ });
82
+ });
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ updateAuthSettingsSchema,
4
+ updateBudgetPolicySchema,
5
+ updateOpenAISettingsSchema,
6
+ } from "@/lib/validators/settings";
7
+
8
+ describe("updateAuthSettingsSchema", () => {
9
+ it("accepts valid oauth method without apiKey", () => {
10
+ const result = updateAuthSettingsSchema.safeParse({ method: "oauth" });
11
+ expect(result.success).toBe(true);
12
+ if (result.success) {
13
+ expect(result.data.method).toBe("oauth");
14
+ expect(result.data.apiKey).toBeUndefined();
15
+ }
16
+ });
17
+
18
+ it("accepts valid api_key method without apiKey", () => {
19
+ const result = updateAuthSettingsSchema.safeParse({ method: "api_key" });
20
+ expect(result.success).toBe(true);
21
+ });
22
+
23
+ it("accepts api_key method with valid apiKey", () => {
24
+ const result = updateAuthSettingsSchema.safeParse({
25
+ method: "api_key",
26
+ apiKey: "sk-ant-abc123",
27
+ });
28
+ expect(result.success).toBe(true);
29
+ if (result.success) {
30
+ expect(result.data.apiKey).toBe("sk-ant-abc123");
31
+ }
32
+ });
33
+
34
+ it("rejects apiKey not starting with sk-ant-", () => {
35
+ const result = updateAuthSettingsSchema.safeParse({
36
+ method: "api_key",
37
+ apiKey: "invalid-key",
38
+ });
39
+ expect(result.success).toBe(false);
40
+ if (!result.success) {
41
+ const flat = result.error.flatten();
42
+ expect(flat.fieldErrors.apiKey).toBeDefined();
43
+ expect(flat.fieldErrors.apiKey![0]).toContain("sk-ant-");
44
+ }
45
+ });
46
+
47
+ it("rejects invalid method value", () => {
48
+ const result = updateAuthSettingsSchema.safeParse({ method: "bearer" });
49
+ expect(result.success).toBe(false);
50
+ });
51
+
52
+ it("rejects missing method field", () => {
53
+ const result = updateAuthSettingsSchema.safeParse({});
54
+ expect(result.success).toBe(false);
55
+ });
56
+
57
+ it("rejects empty apiKey string", () => {
58
+ const result = updateAuthSettingsSchema.safeParse({
59
+ method: "api_key",
60
+ apiKey: "",
61
+ });
62
+ expect(result.success).toBe(false);
63
+ });
64
+
65
+ it("accepts oauth method with valid apiKey (schema allows it)", () => {
66
+ const result = updateAuthSettingsSchema.safeParse({
67
+ method: "oauth",
68
+ apiKey: "sk-ant-test123",
69
+ });
70
+ expect(result.success).toBe(true);
71
+ });
72
+
73
+ it("rejects extra unknown fields via strict parsing", () => {
74
+ const result = updateAuthSettingsSchema.safeParse({
75
+ method: "oauth",
76
+ extra: "field",
77
+ });
78
+ // Zod v4 object schemas strip unknown fields by default
79
+ expect(result.success).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe("updateOpenAISettingsSchema", () => {
84
+ it("accepts valid OpenAI API keys", () => {
85
+ const result = updateOpenAISettingsSchema.safeParse({
86
+ apiKey: "sk-test-openai",
87
+ });
88
+
89
+ expect(result.success).toBe(true);
90
+ });
91
+
92
+ it("rejects keys without the sk- prefix", () => {
93
+ const result = updateOpenAISettingsSchema.safeParse({
94
+ apiKey: "invalid",
95
+ });
96
+
97
+ expect(result.success).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe("updateBudgetPolicySchema", () => {
102
+ it("accepts nullable budget caps for all windows", () => {
103
+ const result = updateBudgetPolicySchema.safeParse({
104
+ overall: {
105
+ dailySpendCapUsd: null,
106
+ monthlySpendCapUsd: 50,
107
+ },
108
+ runtimes: {
109
+ "claude-code": {
110
+ dailySpendCapUsd: 10,
111
+ monthlySpendCapUsd: 100,
112
+ dailyTokenCap: 10000,
113
+ monthlyTokenCap: null,
114
+ },
115
+ "openai-codex-app-server": {
116
+ dailySpendCapUsd: null,
117
+ monthlySpendCapUsd: null,
118
+ dailyTokenCap: null,
119
+ monthlyTokenCap: 50000,
120
+ },
121
+ },
122
+ });
123
+
124
+ expect(result.success).toBe(true);
125
+ });
126
+
127
+ it("rejects zero and negative values", () => {
128
+ const result = updateBudgetPolicySchema.safeParse({
129
+ overall: {
130
+ dailySpendCapUsd: 0,
131
+ monthlySpendCapUsd: -1,
132
+ },
133
+ runtimes: {
134
+ "claude-code": {
135
+ dailySpendCapUsd: null,
136
+ monthlySpendCapUsd: null,
137
+ dailyTokenCap: 0,
138
+ monthlyTokenCap: null,
139
+ },
140
+ "openai-codex-app-server": {
141
+ dailySpendCapUsd: null,
142
+ monthlySpendCapUsd: null,
143
+ dailyTokenCap: null,
144
+ monthlyTokenCap: null,
145
+ },
146
+ },
147
+ });
148
+
149
+ expect(result.success).toBe(false);
150
+ });
151
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTaskSchema, updateTaskSchema } from "@/lib/validators/task";
3
+
4
+ describe("createTaskSchema", () => {
5
+ it("accepts minimal valid input", () => {
6
+ const result = createTaskSchema.safeParse({ title: "Do something" });
7
+ expect(result.success).toBe(true);
8
+ if (result.success) {
9
+ expect(result.data.priority).toBe(2); // default
10
+ }
11
+ });
12
+
13
+ it("accepts full valid input", () => {
14
+ const result = createTaskSchema.safeParse({
15
+ title: "Task",
16
+ description: "Details",
17
+ projectId: "proj-1",
18
+ priority: 0,
19
+ assignedAgent: "claude-code",
20
+ });
21
+ expect(result.success).toBe(true);
22
+ });
23
+
24
+ it("accepts all supported runtime values", () => {
25
+ const result = createTaskSchema.safeParse({
26
+ title: "Task",
27
+ assignedAgent: "openai-codex-app-server",
28
+ });
29
+ expect(result.success).toBe(true);
30
+ });
31
+
32
+ it("rejects unsupported assignedAgent values", () => {
33
+ const result = createTaskSchema.safeParse({
34
+ title: "Task",
35
+ assignedAgent: "unknown-runtime",
36
+ });
37
+ expect(result.success).toBe(false);
38
+ });
39
+
40
+ it("rejects empty title", () => {
41
+ const result = createTaskSchema.safeParse({ title: "" });
42
+ expect(result.success).toBe(false);
43
+ });
44
+
45
+ it("rejects title exceeding 200 characters", () => {
46
+ const result = createTaskSchema.safeParse({ title: "a".repeat(201) });
47
+ expect(result.success).toBe(false);
48
+ });
49
+
50
+ it("rejects description exceeding 2000 characters", () => {
51
+ const result = createTaskSchema.safeParse({
52
+ title: "Test",
53
+ description: "a".repeat(2001),
54
+ });
55
+ expect(result.success).toBe(false);
56
+ });
57
+
58
+ it("rejects priority below 0", () => {
59
+ const result = createTaskSchema.safeParse({ title: "Test", priority: -1 });
60
+ expect(result.success).toBe(false);
61
+ });
62
+
63
+ it("rejects priority above 3", () => {
64
+ const result = createTaskSchema.safeParse({ title: "Test", priority: 4 });
65
+ expect(result.success).toBe(false);
66
+ });
67
+
68
+ it("accepts all valid priority values (0-3)", () => {
69
+ for (const priority of [0, 1, 2, 3]) {
70
+ const result = createTaskSchema.safeParse({ title: "Test", priority });
71
+ expect(result.success).toBe(true);
72
+ }
73
+ });
74
+
75
+ it("accepts fileIds as optional array of strings", () => {
76
+ const result = createTaskSchema.safeParse({
77
+ title: "Test",
78
+ fileIds: ["abc-123", "def-456"],
79
+ });
80
+ expect(result.success).toBe(true);
81
+ if (result.success) {
82
+ expect(result.data.fileIds).toEqual(["abc-123", "def-456"]);
83
+ }
84
+ });
85
+
86
+ it("accepts task without fileIds", () => {
87
+ const result = createTaskSchema.safeParse({ title: "Test" });
88
+ expect(result.success).toBe(true);
89
+ if (result.success) {
90
+ expect(result.data.fileIds).toBeUndefined();
91
+ }
92
+ });
93
+
94
+ it("accepts empty fileIds array", () => {
95
+ const result = createTaskSchema.safeParse({ title: "Test", fileIds: [] });
96
+ expect(result.success).toBe(true);
97
+ if (result.success) {
98
+ expect(result.data.fileIds).toEqual([]);
99
+ }
100
+ });
101
+
102
+ it("rejects fileIds with non-string elements", () => {
103
+ const result = createTaskSchema.safeParse({
104
+ title: "Test",
105
+ fileIds: [123, true],
106
+ });
107
+ expect(result.success).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe("updateTaskSchema", () => {
112
+ it("accepts empty object", () => {
113
+ const result = updateTaskSchema.safeParse({});
114
+ expect(result.success).toBe(true);
115
+ });
116
+
117
+ it("accepts valid status transitions", () => {
118
+ const statuses = ["planned", "queued", "running", "completed", "failed", "cancelled"];
119
+ for (const status of statuses) {
120
+ const result = updateTaskSchema.safeParse({ status });
121
+ expect(result.success).toBe(true);
122
+ }
123
+ });
124
+
125
+ it("rejects invalid status", () => {
126
+ const result = updateTaskSchema.safeParse({ status: "pending" });
127
+ expect(result.success).toBe(false);
128
+ });
129
+
130
+ it("accepts result and sessionId fields", () => {
131
+ const result = updateTaskSchema.safeParse({
132
+ result: "Task output here",
133
+ sessionId: "session-abc",
134
+ });
135
+ expect(result.success).toBe(true);
136
+ });
137
+
138
+ it("rejects unsupported assignedAgent values on update", () => {
139
+ const result = updateTaskSchema.safeParse({
140
+ assignedAgent: "unknown-runtime",
141
+ });
142
+ expect(result.success).toBe(false);
143
+ });
144
+ });
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { WorkflowDefinition } from "../types";
3
+ import { validateWorkflowDefinition } from "../definition-validation";
4
+
5
+ function createValidParallelDefinition(): WorkflowDefinition {
6
+ return {
7
+ pattern: "parallel",
8
+ steps: [
9
+ {
10
+ id: "branch-a",
11
+ name: "Market scan",
12
+ prompt: "Research market trends.",
13
+ },
14
+ {
15
+ id: "branch-b",
16
+ name: "Customer scan",
17
+ prompt: "Review customer feedback.",
18
+ },
19
+ {
20
+ id: "join",
21
+ name: "Synthesize",
22
+ prompt: "Combine the findings.",
23
+ dependsOn: ["branch-a", "branch-b"],
24
+ },
25
+ ],
26
+ };
27
+ }
28
+
29
+ function createValidSwarmDefinition(): WorkflowDefinition {
30
+ return {
31
+ pattern: "swarm",
32
+ steps: [
33
+ {
34
+ id: "mayor",
35
+ name: "Mayor",
36
+ prompt: "Plan the swarm.",
37
+ },
38
+ {
39
+ id: "worker-a",
40
+ name: "Worker A",
41
+ prompt: "Own the first slice.",
42
+ },
43
+ {
44
+ id: "worker-b",
45
+ name: "Worker B",
46
+ prompt: "Own the second slice.",
47
+ },
48
+ {
49
+ id: "refinery",
50
+ name: "Refinery",
51
+ prompt: "Merge the results.",
52
+ },
53
+ ],
54
+ swarmConfig: {
55
+ workerConcurrencyLimit: 2,
56
+ },
57
+ };
58
+ }
59
+
60
+ describe("validateWorkflowDefinition", () => {
61
+ it("accepts a valid parallel fork/join definition", () => {
62
+ expect(validateWorkflowDefinition(createValidParallelDefinition())).toBeNull();
63
+ });
64
+
65
+ it("rejects a parallel definition with too few branches", () => {
66
+ const definition: WorkflowDefinition = {
67
+ pattern: "parallel",
68
+ steps: [
69
+ {
70
+ id: "branch-a",
71
+ name: "Single branch",
72
+ prompt: "Only one branch exists.",
73
+ },
74
+ {
75
+ id: "join",
76
+ name: "Synthesize",
77
+ prompt: "Combine the findings.",
78
+ dependsOn: ["branch-a"],
79
+ },
80
+ ],
81
+ };
82
+
83
+ expect(validateWorkflowDefinition(definition)).toBe(
84
+ "Parallel pattern requires at least 2 branch steps"
85
+ );
86
+ });
87
+
88
+ it("rejects a synthesis step that does not depend on every branch", () => {
89
+ const definition = createValidParallelDefinition();
90
+ definition.steps[2] = {
91
+ ...definition.steps[2],
92
+ dependsOn: ["branch-a"],
93
+ };
94
+
95
+ expect(validateWorkflowDefinition(definition)).toBe(
96
+ "Parallel synthesis step must depend on every branch exactly once"
97
+ );
98
+ });
99
+
100
+ it("keeps loop validation intact", () => {
101
+ const definition: WorkflowDefinition = {
102
+ pattern: "loop",
103
+ steps: [{ id: "loop", name: "Loop", prompt: "Keep iterating." }],
104
+ };
105
+
106
+ expect(validateWorkflowDefinition(definition)).toBe(
107
+ "Loop pattern requires loopConfig with maxIterations >= 1"
108
+ );
109
+ });
110
+
111
+ it("accepts a valid swarm definition", () => {
112
+ expect(validateWorkflowDefinition(createValidSwarmDefinition())).toBeNull();
113
+ });
114
+
115
+ it("rejects a swarm definition with too few workers", () => {
116
+ const definition: WorkflowDefinition = {
117
+ pattern: "swarm",
118
+ steps: [
119
+ {
120
+ id: "mayor",
121
+ name: "Mayor",
122
+ prompt: "Plan the swarm.",
123
+ },
124
+ {
125
+ id: "worker-a",
126
+ name: "Worker A",
127
+ prompt: "Own the only slice.",
128
+ },
129
+ {
130
+ id: "refinery",
131
+ name: "Refinery",
132
+ prompt: "Merge the results.",
133
+ },
134
+ ],
135
+ };
136
+
137
+ expect(validateWorkflowDefinition(definition)).toBe(
138
+ "Swarm pattern requires a mayor step, 2-5 worker steps, and a refinery step"
139
+ );
140
+ });
141
+
142
+ it("rejects swarm steps that declare dependencies", () => {
143
+ const definition = createValidSwarmDefinition();
144
+ definition.steps[1] = {
145
+ ...definition.steps[1],
146
+ dependsOn: ["mayor"],
147
+ };
148
+
149
+ expect(validateWorkflowDefinition(definition)).toBe(
150
+ "Swarm steps use fixed mayor/worker/refinery ordering and cannot declare dependencies"
151
+ );
152
+ });
153
+
154
+ it("rejects a swarm concurrency limit above worker count", () => {
155
+ const definition = createValidSwarmDefinition();
156
+ definition.swarmConfig = {
157
+ workerConcurrencyLimit: 3,
158
+ };
159
+
160
+ expect(validateWorkflowDefinition(definition)).toBe(
161
+ "Swarm worker concurrency limit must be between 1 and 2"
162
+ );
163
+ });
164
+ });