iflow-mcp-deeflect-smart-spawn 0.1.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,299 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
+ import { ArtifactStorage } from "../src/storage.ts";
9
+ import { McpStore } from "../src/db.ts";
10
+ import { RuntimeQueue } from "../src/runtime/queue.ts";
11
+ import { registerToolHandlers } from "../src/tools.ts";
12
+ import type { McpConfig } from "../src/config.ts";
13
+
14
+ const cleanupDirs: string[] = [];
15
+
16
+ afterEach(() => {
17
+ while (cleanupDirs.length > 0) {
18
+ const dir = cleanupDirs.pop();
19
+ if (!dir) continue;
20
+ rmSync(dir, { recursive: true, force: true });
21
+ }
22
+ });
23
+
24
+ class MockSmartSpawnClient {
25
+ async pick(params: { task: string; budget?: string; context?: string; exclude?: string[] }) {
26
+ const budget = params.budget ?? "medium";
27
+ if (budget === "low") {
28
+ return { modelId: "openai/gpt-4o-mini", reason: "cheap pick" };
29
+ }
30
+ if ((params.exclude ?? []).includes("anthropic/claude-sonnet-4")) {
31
+ return { modelId: "openai/gpt-4o", reason: "alternate pick" };
32
+ }
33
+ return { modelId: "anthropic/claude-sonnet-4", reason: "default pick" };
34
+ }
35
+
36
+ async recommend(params: { count?: number }) {
37
+ const count = Math.max(1, Math.min(params.count ?? 3, 5));
38
+ const models = [
39
+ "openai/gpt-4o-mini",
40
+ "anthropic/claude-sonnet-4",
41
+ "google/gemini-2.5-pro",
42
+ "openai/gpt-4o",
43
+ "meta-llama/llama-3.3-70b-instruct",
44
+ ];
45
+ return models.slice(0, count).map((modelId, idx) => ({
46
+ modelId,
47
+ reason: `recommend-${idx + 1}`,
48
+ }));
49
+ }
50
+
51
+ async decompose(params: { task: string }) {
52
+ if (!params.task.toLowerCase().includes(" and ")) {
53
+ return { decomposed: false, steps: [] };
54
+ }
55
+ return {
56
+ decomposed: true,
57
+ steps: [
58
+ {
59
+ id: "step-1",
60
+ task: "Design API contracts",
61
+ modelId: "anthropic/claude-sonnet-4",
62
+ wave: 0,
63
+ dependsOn: [],
64
+ reason: "step-1",
65
+ },
66
+ {
67
+ id: "step-2",
68
+ task: "Implement API handlers",
69
+ modelId: "openai/gpt-4o-mini",
70
+ wave: 1,
71
+ dependsOn: ["step-1"],
72
+ reason: "step-2",
73
+ },
74
+ ],
75
+ };
76
+ }
77
+
78
+ async swarm() {
79
+ return {
80
+ decomposed: true,
81
+ tasks: [
82
+ {
83
+ id: "swarm-1",
84
+ task: "Create backend service",
85
+ modelId: "anthropic/claude-sonnet-4",
86
+ wave: 0,
87
+ dependsOn: [],
88
+ reason: "backend",
89
+ },
90
+ {
91
+ id: "swarm-2",
92
+ task: "Create frontend service",
93
+ modelId: "openai/gpt-4o-mini",
94
+ wave: 0,
95
+ dependsOn: [],
96
+ reason: "frontend",
97
+ },
98
+ {
99
+ id: "swarm-3",
100
+ task: "Write integration tests",
101
+ modelId: "openai/gpt-4o",
102
+ wave: 1,
103
+ dependsOn: ["swarm-1", "swarm-2"],
104
+ reason: "tests",
105
+ },
106
+ ],
107
+ };
108
+ }
109
+
110
+ async composeRole(task: string) {
111
+ return task;
112
+ }
113
+
114
+ async health() {
115
+ return { reachable: true, payload: { ok: true } };
116
+ }
117
+ }
118
+
119
+ class MockOpenRouterClient {
120
+ private calls = 0;
121
+
122
+ async chatCompletion(input: { model: string; messages: Array<{ role: string; content: string }> }) {
123
+ this.calls += 1;
124
+ const prompt = input.messages.map((m) => m.content).join("\n");
125
+ const isMerge = prompt.includes("You are merging outputs");
126
+
127
+ await Bun.sleep(8);
128
+
129
+ return {
130
+ text: isMerge
131
+ ? `Merged final answer from ${input.model}.`
132
+ : `Node answer ${this.calls} from ${input.model}.`,
133
+ promptTokens: 120 + this.calls,
134
+ completionTokens: 80 + this.calls,
135
+ totalTokens: 200 + this.calls * 2,
136
+ };
137
+ }
138
+ }
139
+
140
+ function parseToolPayload(result: any): any {
141
+ const text = result?.content?.find((part: any) => part?.type === "text")?.text;
142
+ expect(typeof text).toBe("string");
143
+ return JSON.parse(text);
144
+ }
145
+
146
+ async function waitForRunCompletion(client: Client, runId: string, maxMs = 5000): Promise<any> {
147
+ const started = Date.now();
148
+ while (Date.now() - started < maxMs) {
149
+ const statusResult = await client.callTool({
150
+ name: "smartspawn_run_status",
151
+ arguments: { run_id: runId },
152
+ });
153
+ const payload = parseToolPayload(statusResult);
154
+ if (["completed", "failed", "canceled"].includes(payload.status)) {
155
+ return payload;
156
+ }
157
+ await Bun.sleep(40);
158
+ }
159
+ throw new Error(`Run did not complete in ${maxMs}ms`);
160
+ }
161
+
162
+ function buildTestConfig(homeDir: string): McpConfig {
163
+ return {
164
+ openRouterApiKey: "test-key",
165
+ smartSpawnApiUrl: "http://localhost/mock",
166
+ homeDir,
167
+ dbPath: join(homeDir, "db.sqlite"),
168
+ artifactsDir: join(homeDir, "artifacts"),
169
+ maxParallelRuns: 2,
170
+ maxParallelNodesPerRun: 4,
171
+ maxUsdPerRun: 50,
172
+ nodeTimeoutSeconds: 30,
173
+ runTimeoutSeconds: 120,
174
+ pollIntervalMs: 20,
175
+ };
176
+ }
177
+
178
+ async function withMcpHarness<T>(fn: (ctx: { client: Client; runtime: RuntimeQueue }) => Promise<T>): Promise<T> {
179
+ const homeDir = mkdtempSync(join(tmpdir(), "smart-spawn-mcp-test-"));
180
+ cleanupDirs.push(homeDir);
181
+
182
+ const config = buildTestConfig(homeDir);
183
+ const store = new McpStore(config.dbPath);
184
+ const storage = new ArtifactStorage(config.homeDir, config.artifactsDir);
185
+ const runtime = new RuntimeQueue(
186
+ config,
187
+ store,
188
+ storage,
189
+ new MockSmartSpawnClient() as any,
190
+ new MockOpenRouterClient() as any
191
+ );
192
+ await runtime.start();
193
+
194
+ const server = new Server(
195
+ { name: "smart-spawn-mcp-test", version: "0.0.0-test" },
196
+ { capabilities: { tools: {} } }
197
+ );
198
+ registerToolHandlers(server, runtime);
199
+
200
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
201
+ const client = new Client({ name: "smart-spawn-test-client", version: "1.0.0" });
202
+
203
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
204
+
205
+ try {
206
+ return await fn({ client, runtime });
207
+ } finally {
208
+ runtime.stop();
209
+ await client.close();
210
+ await server.close();
211
+ store.close();
212
+ }
213
+ }
214
+
215
+ test("MCP single run lifecycle returns merged output and artifacts", async () => {
216
+ await withMcpHarness(async ({ client }) => {
217
+ const tools = await client.listTools();
218
+ const toolNames = tools.tools.map((t) => t.name);
219
+ expect(toolNames).toContain("smartspawn_run_create");
220
+ expect(toolNames).toContain("smartspawn_run_result");
221
+
222
+ const createResult = await client.callTool({
223
+ name: "smartspawn_run_create",
224
+ arguments: {
225
+ task: "Build a small hello world API",
226
+ mode: "single",
227
+ budget: "low",
228
+ },
229
+ });
230
+ const created = parseToolPayload(createResult);
231
+ const runId = String(created.run_id);
232
+ expect(runId.length).toBeGreaterThan(10);
233
+
234
+ const finalStatus = await waitForRunCompletion(client, runId);
235
+ expect(finalStatus.status).toBe("completed");
236
+
237
+ const result = await client.callTool({
238
+ name: "smartspawn_run_result",
239
+ arguments: { run_id: runId },
240
+ });
241
+ const payload = parseToolPayload(result);
242
+
243
+ expect(payload.status).toBe("completed");
244
+ expect(typeof payload.merged_output).toBe("string");
245
+ expect(payload.merged_output).toContain("Merged Output");
246
+ expect(Array.isArray(payload.artifacts)).toBe(true);
247
+ expect(payload.artifacts.length).toBeGreaterThan(0);
248
+
249
+ const mergedArtifact = await client.callTool({
250
+ name: "smartspawn_artifact_get",
251
+ arguments: { run_id: runId, node_id: "merged" },
252
+ });
253
+ const mergedPayload = parseToolPayload(mergedArtifact);
254
+ expect(mergedPayload.artifact_type).toBe("merged");
255
+ expect(mergedPayload.content).toContain("Merged Output");
256
+ });
257
+ });
258
+
259
+ test("MCP swarm mode runs parallel tasks and returns merged answer", async () => {
260
+ await withMcpHarness(async ({ client }) => {
261
+ const createResult = await client.callTool({
262
+ name: "smartspawn_run_create",
263
+ arguments: {
264
+ task: "Build backend and frontend and tests",
265
+ mode: "swarm",
266
+ budget: "medium",
267
+ },
268
+ });
269
+ const created = parseToolPayload(createResult);
270
+ const runId = String(created.run_id);
271
+
272
+ const finalStatus = await waitForRunCompletion(client, runId);
273
+ expect(finalStatus.status).toBe("completed");
274
+ expect(finalStatus.progress.total_nodes).toBeGreaterThanOrEqual(4);
275
+
276
+ const result = await client.callTool({
277
+ name: "smartspawn_run_result",
278
+ arguments: { run_id: runId, include_raw: true },
279
+ });
280
+ const payload = parseToolPayload(result);
281
+
282
+ expect(payload.status).toBe("completed");
283
+ expect(payload.merged_output).toContain("Merged final answer");
284
+ expect(Array.isArray(payload.raw_outputs)).toBe(true);
285
+ expect(payload.raw_outputs.length).toBeGreaterThanOrEqual(3);
286
+ expect(payload.cost.prompt_tokens).toBeGreaterThan(0);
287
+
288
+ const healthResult = await client.callTool({
289
+ name: "smartspawn_health",
290
+ arguments: {},
291
+ });
292
+ const health = parseToolPayload(healthResult);
293
+ expect(health.openrouter_configured).toBe(true);
294
+ expect(health.smart_spawn_api_reachable).toBe(true);
295
+ expect(health.db_writable).toBe(true);
296
+ expect(health.artifact_storage_writable).toBe(true);
297
+ expect(health.worker_alive).toBe(true);
298
+ });
299
+ });
@@ -0,0 +1,106 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { McpConfig } from "../src/config.ts";
6
+ import { McpStore } from "../src/db.ts";
7
+ import { RuntimeQueue } from "../src/runtime/queue.ts";
8
+ import { ArtifactStorage } from "../src/storage.ts";
9
+ import type { RunRecord } from "../src/types.ts";
10
+
11
+ const cleanupDirs: string[] = [];
12
+
13
+ afterEach(() => {
14
+ while (cleanupDirs.length > 0) {
15
+ const dir = cleanupDirs.pop();
16
+ if (!dir) continue;
17
+ rmSync(dir, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ class MockSmartSpawnClient {
22
+ async pick() {
23
+ return { modelId: "openai/gpt-4o-mini", reason: "mock pick" };
24
+ }
25
+
26
+ async composeRole(task: string) {
27
+ return task;
28
+ }
29
+
30
+ async health() {
31
+ return { reachable: true, payload: { ok: true } };
32
+ }
33
+ }
34
+
35
+ class SlowOpenRouterClient {
36
+ async chatCompletion() {
37
+ await Bun.sleep(1500);
38
+ return {
39
+ text: "late output",
40
+ promptTokens: 100,
41
+ completionTokens: 100,
42
+ totalTokens: 200,
43
+ };
44
+ }
45
+ }
46
+
47
+ function buildTestConfig(homeDir: string): McpConfig {
48
+ return {
49
+ openRouterApiKey: "test-key",
50
+ smartSpawnApiUrl: "http://localhost/mock",
51
+ homeDir,
52
+ dbPath: join(homeDir, "db.sqlite"),
53
+ artifactsDir: join(homeDir, "artifacts"),
54
+ maxParallelRuns: 1,
55
+ maxParallelNodesPerRun: 1,
56
+ maxUsdPerRun: 50,
57
+ nodeTimeoutSeconds: 1,
58
+ runTimeoutSeconds: 30,
59
+ pollIntervalMs: 20,
60
+ };
61
+ }
62
+
63
+ async function waitForTerminal(runtime: RuntimeQueue, runId: string, maxMs = 12000): Promise<RunRecord> {
64
+ const started = Date.now();
65
+ while (Date.now() - started < maxMs) {
66
+ const run = runtime.getRun(runId);
67
+ if (!run) throw new Error(`run not found: ${runId}`);
68
+ if (["completed", "failed", "canceled"].includes(run.status)) return run;
69
+ await Bun.sleep(50);
70
+ }
71
+ throw new Error(`Run did not reach terminal state in ${maxMs}ms`);
72
+ }
73
+
74
+ test("run fails when a node exceeds node timeout", async () => {
75
+ const homeDir = mkdtempSync(join(tmpdir(), "smart-spawn-timeout-test-"));
76
+ cleanupDirs.push(homeDir);
77
+
78
+ const config = buildTestConfig(homeDir);
79
+ const store = new McpStore(config.dbPath);
80
+ const storage = new ArtifactStorage(config.homeDir, config.artifactsDir);
81
+ const runtime = new RuntimeQueue(
82
+ config,
83
+ store,
84
+ storage,
85
+ new MockSmartSpawnClient() as any,
86
+ new SlowOpenRouterClient() as any
87
+ );
88
+
89
+ await runtime.start();
90
+ try {
91
+ const run = await runtime.createRun({
92
+ task: "Return a short answer",
93
+ mode: "single",
94
+ budget: "low",
95
+ });
96
+
97
+ const finalRun = await waitForTerminal(runtime, run.id);
98
+ expect(finalRun.status).toBe("failed");
99
+
100
+ const events = store.listRecentEvents(run.id, 30);
101
+ expect(events.some((event) => event.message.includes("timed out after 1s"))).toBe(true);
102
+ } finally {
103
+ runtime.stop();
104
+ store.close();
105
+ }
106
+ });
@@ -0,0 +1,11 @@
1
+ import { expect, test } from "bun:test";
2
+ import { buildSinglePlan } from "../src/runtime/planner.ts";
3
+
4
+ test("buildSinglePlan creates exactly one node in fallback mode", async () => {
5
+ const plan = await buildSinglePlan(
6
+ { task: "Write a test", mode: "single", budget: "medium" },
7
+ undefined
8
+ );
9
+ expect(plan.nodes.length).toBe(1);
10
+ expect(plan.nodes[0]?.kind).toBe("task");
11
+ });
@@ -0,0 +1,13 @@
1
+ import { expect, test } from "bun:test";
2
+ import { listToolNames } from "../src/tools.ts";
3
+
4
+ test("registers required tool names", () => {
5
+ const names = listToolNames();
6
+ expect(names).toContain("smartspawn_run_create");
7
+ expect(names).toContain("smartspawn_run_status");
8
+ expect(names).toContain("smartspawn_run_result");
9
+ expect(names).toContain("smartspawn_run_cancel");
10
+ expect(names).toContain("smartspawn_run_list");
11
+ expect(names).toContain("smartspawn_artifact_get");
12
+ expect(names).toContain("smartspawn_health");
13
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowImportingTsExtensions": true,
8
+ "verbatimModuleSyntax": true,
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["src/**/*.ts", "tests/**/*.ts"]
15
+ }