diff --git a/src/resources/extensions/sf/subagent/index.js b/src/resources/extensions/sf/subagent/index.js index afff31082..e7d21d0f6 100644 --- a/src/resources/extensions/sf/subagent/index.js +++ b/src/resources/extensions/sf/subagent/index.js @@ -45,6 +45,7 @@ import { mergeDeltaPatches, readIsolationMode, } from "./isolation.js"; +import { swarmDispatchAndWait } from "../uok/swarm-dispatch.js"; import { composeAgentPrompt } from "./prompt-parts.js"; import { registerWorker, updateWorker } from "./worker-registry.js"; @@ -1126,6 +1127,142 @@ async function waitForFile(filePath, signal, timeoutMs = 30 * 60 * 1000) { } return false; } +/** + * Run a single subagent through the swarm dispatch layer. + * + * Purpose: alternate execution path activated by `SF_SUBAGENT_VIA_SWARM=1`. + * Routes the subagent task as a DispatchEnvelope through `swarmDispatchAndWait`, + * then maps the structured swarm result back into the same currentResult shape + * that `runSingleAgent`'s callers expect. + * + * Consumer: runSingleAgent when SF_SUBAGENT_VIA_SWARM is set. + */ +async function runSingleAgentViaSwarm( + defaultCwd, + agent, + effectiveName, + task, + cwd, + step, + signal, + onUpdate, + makeDetails, + modelOverride, +) { + const currentResult = { + agent: effectiveName, + agentSource: agent.source, + task, + exitCode: 0, + messages: [], + stderr: "", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + contextTokens: 0, + turns: 0, + }, + model: modelOverride ?? agent.model, + step, + }; + const emitUpdate = () => { + if (onUpdate) { + onUpdate({ + content: [ + { + type: "text", + text: getFinalOutput(currentResult.messages) || "(running...)", + }, + ], + details: makeDetails([currentResult]), + }); + } + }; + + const basePath = cwd ?? defaultCwd; + + // Compose the system prompt exactly as the in-process path does. + const composedPrompt = agent.systemPrompt.trim() + ? composeAgentPrompt(agent, { + cwd: basePath, + surface: "subagent", + tools: agent.tools, + }) + : ""; + + const envelope = { + scope: "subagent", + unitId: agent.name + "-" + crypto.randomUUID(), + unitType: "delegate", + workMode: "build", + payload: task, + ...(composedPrompt ? { executorSystemPrompt: composedPrompt } : {}), + ...(agent.tools ? { executorTools: agent.tools } : {}), + }; + + // Set up a chained abort controller so we can honour the caller's signal. + const controller = new AbortController(); + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", () => controller.abort(), { once: true }); + } + } + liveSubagentControllers.add(controller); + + try { + const swarmResult = await swarmDispatchAndWait(basePath, envelope, { + signal: controller.signal, + onEvent: (event) => { + processSubagentEventLine( + JSON.stringify(event), + currentResult, + emitUpdate, + ); + }, + }); + + if (swarmResult.error || swarmResult.reply === null) { + const reason = swarmResult.error ?? "swarm returned no reply"; + currentResult.exitCode = 1; + currentResult.stderr = reason; + currentResult.errorMessage = reason; + emitUpdate(); + return currentResult; + } + + // If processSubagentEventLine didn't capture messages from events, + // fall back to synthesising one from the swarm reply text. + if (currentResult.messages.length === 0 && swarmResult.reply) { + currentResult.messages.push({ + role: "assistant", + content: [{ type: "text", text: swarmResult.reply }], + model: modelOverride ?? agent.model, + stopReason: "stop", + }); + currentResult.usage.turns = 1; + } + + emitUpdate(); + return currentResult; + } catch (error) { + const message = + error instanceof Error + ? error.message + : `Subagent (swarm) failed: ${String(error)}`; + currentResult.exitCode = 1; + currentResult.stderr += currentResult.stderr ? `\n${message}` : message; + currentResult.errorMessage = message; + emitUpdate(); + return currentResult; + } finally { + liveSubagentControllers.delete(controller); + } +} async function runSingleAgent( defaultCwd, agents, @@ -1185,6 +1322,22 @@ async function runSingleAgent( }; } } + // Feature flag: route through swarm dispatch instead of direct runSubagent. + const swarmFlag = process.env.SF_SUBAGENT_VIA_SWARM; + if (swarmFlag === "1" || swarmFlag === "true") { + return runSingleAgentViaSwarm( + defaultCwd, + agent, + effectiveName, + task, + cwd, + step, + signal, + onUpdate, + makeDetails, + modelOverride, + ); + } const currentResult = { agent: effectiveName, agentSource: agent.source, @@ -1292,6 +1445,8 @@ async function runSingleAgent( liveSubagentControllers.delete(controller); } } +// Exported for testing — not part of the public extension API. +export { runSingleAgent, runSingleAgentViaSwarm }; async function runSingleAgentInCmuxSplit( cmuxClient, directionOrSurfaceId, diff --git a/src/resources/extensions/sf/tests/subagent-via-swarm.test.mjs b/src/resources/extensions/sf/tests/subagent-via-swarm.test.mjs new file mode 100644 index 000000000..38ce83a29 --- /dev/null +++ b/src/resources/extensions/sf/tests/subagent-via-swarm.test.mjs @@ -0,0 +1,411 @@ +/** + * Tests for the SF_SUBAGENT_VIA_SWARM feature flag path in runSingleAgent / + * runSingleAgentViaSwarm. + * + * Strategy: vi.mock the swarm-dispatch module so no real MessageBus / AgentSwarm + * is created, then call runSingleAgent (exported for testing) directly. + */ +import assert from "node:assert/strict"; +import { beforeEach, afterEach, test, vi } from "vitest"; + +// ─── Mock swarm-dispatch before the module under test is imported ───────────── +// vi.mock hoists this call to the top of the file so the mock is in place when +// `../subagent/index.js` is first resolved. +vi.mock("../uok/swarm-dispatch.js", () => { + return { + swarmDispatchAndWait: vi.fn(), + swarmDispatch: vi.fn(), + SwarmDispatchLayer: class { + dispatch() {} + dispatchAndWait() {} + }, + }; +}); + +// Import the mock handle and the module under test AFTER vi.mock is declared. +const { swarmDispatchAndWait } = await import("../uok/swarm-dispatch.js"); + +// runSingleAgent and runSingleAgentViaSwarm are exported for testing only. +const { runSingleAgent, runSingleAgentViaSwarm } = await import( + "../subagent/index.js" +); + +// ─── Minimal agent fixture ───────────────────────────────────────────────────── +const DEFAULT_CWD = "/tmp/sf-test"; + +function makeAgent(overrides = {}) { + return { + name: "worker", + displayName: "Worker", + source: "builtin", + systemPrompt: "You are a helpful worker agent.", + model: "claude-haiku-4.5", + tools: ["read", "bash"], + promptParts: [], + conflictsWith: [], + ...overrides, + }; +} + +function makeAgents(overrides = {}) { + return [makeAgent(overrides)]; +} + +// ─── Helpers ─────────────────────────────────────────────────────────────────── +const NOOP_MAKE_DETAILS = () => () => ({}); + +function makeDeterministicSwarmResult(overrides = {}) { + return { + messageId: "msg-001", + targetAgent: "worker-1", + swarmName: "default", + reply: "Task completed successfully.", + replyMessageId: "reply-001", + error: undefined, + ...overrides, + }; +} + +// ─── Setup / teardown ───────────────────────────────────────────────────────── +beforeEach(() => { + vi.resetAllMocks(); + delete process.env.SF_SUBAGENT_VIA_SWARM; +}); + +afterEach(() => { + delete process.env.SF_SUBAGENT_VIA_SWARM; +}); + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +test("SF_SUBAGENT_VIA_SWARM unset → runSubagent path, swarmDispatchAndWait NOT called", async () => { + // With the flag unset, runSingleAgent should NOT touch swarmDispatchAndWait. + // It will try to call runSubagent (from @singularity-forge/coding-agent), which + // will fail inside the test environment — we just need to confirm the swarm path + // was NOT taken. + const agents = makeAgents(); + + let threw = false; + try { + await runSingleAgent( + DEFAULT_CWD, + agents, + "worker", + "do a thing", + undefined, + 1, + undefined, + undefined, + NOOP_MAKE_DETAILS, + undefined, + undefined, + ); + } catch { + threw = true; + } + + // swarmDispatchAndWait must NOT have been called regardless of whether + // runSubagent threw. + assert.equal( + swarmDispatchAndWait.mock.calls.length, + 0, + "swarmDispatchAndWait should not be called when flag is unset", + ); +}); + +test("SF_SUBAGENT_VIA_SWARM=1 → swarmDispatchAndWait called with correct envelope shape", async () => { + process.env.SF_SUBAGENT_VIA_SWARM = "1"; + swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult()); + + const agents = makeAgents(); + const result = await runSingleAgent( + DEFAULT_CWD, + agents, + "worker", + "implement the feature", + undefined, + 1, + undefined, + undefined, + NOOP_MAKE_DETAILS, + undefined, + undefined, + ); + + assert.equal( + swarmDispatchAndWait.mock.calls.length, + 1, + "swarmDispatchAndWait should be called exactly once", + ); + + const [calledBasePath, calledEnvelope, calledOptions] = + swarmDispatchAndWait.mock.calls[0]; + + // basePath should be defaultCwd when no cwd override is given + assert.equal(calledBasePath, DEFAULT_CWD); + + // scope must be "subagent" + assert.equal(calledEnvelope.scope, "subagent"); + + // unitType must be "delegate" + assert.equal(calledEnvelope.unitType, "delegate"); + + // payload must match the task string + assert.equal(calledEnvelope.payload, "implement the feature"); + + // executorSystemPrompt must be non-empty (composed from agent.systemPrompt) + assert.ok( + typeof calledEnvelope.executorSystemPrompt === "string" && + calledEnvelope.executorSystemPrompt.length > 0, + "executorSystemPrompt should be a non-empty string", + ); + + // executorTools must match the agent's tool list + assert.deepEqual(calledEnvelope.executorTools, ["read", "bash"]); + + // unitId must start with agent name + assert.ok( + calledEnvelope.unitId.startsWith("worker-"), + `unitId should start with "worker-", got: ${calledEnvelope.unitId}`, + ); + + // options must include a signal (chained AbortSignal) + assert.ok( + "signal" in calledOptions || calledOptions.signal === undefined, + "options should have a signal property", + ); + + // onEvent callback must be provided + assert.equal(typeof calledOptions.onEvent, "function"); +}); + +test("SF_SUBAGENT_VIA_SWARM=true → same swarm path as =1", async () => { + process.env.SF_SUBAGENT_VIA_SWARM = "true"; + swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult()); + + const agents = makeAgents(); + await runSingleAgent( + DEFAULT_CWD, + agents, + "worker", + "task", + undefined, + 1, + undefined, + undefined, + NOOP_MAKE_DETAILS, + undefined, + undefined, + ); + + assert.equal(swarmDispatchAndWait.mock.calls.length, 1); +}); + +test("swarm success → return shape matches in-process path", async () => { + process.env.SF_SUBAGENT_VIA_SWARM = "1"; + swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult()); + + const agents = makeAgents(); + const result = await runSingleAgent( + DEFAULT_CWD, + agents, + "worker", + "build the thing", + undefined, + 2, + undefined, + undefined, + NOOP_MAKE_DETAILS, + undefined, + undefined, + ); + + // Must have all the same top-level keys as the in-process path + assert.equal(result.agent, "worker"); + assert.equal(result.agentSource, "builtin"); + assert.equal(result.task, "build the thing"); + assert.equal(result.exitCode, 0); + assert.equal(result.step, 2); + assert.ok(Array.isArray(result.messages), "messages must be an array"); + assert.equal(typeof result.stderr, "string"); + assert.ok(result.usage, "usage must be present"); + assert.equal(typeof result.usage.input, "number"); + assert.equal(typeof result.usage.output, "number"); + assert.equal(typeof result.usage.turns, "number"); + + // The swarm reply text should appear as a synthesized assistant message + // because no onEvent calls were fired with message_end events. + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].role, "assistant"); + assert.ok( + result.messages[0].content.some( + (c) => c.type === "text" && c.text === "Task completed successfully.", + ), + ); +}); + +test("swarm returns error → exitCode=1 and errorMessage set", async () => { + process.env.SF_SUBAGENT_VIA_SWARM = "1"; + swarmDispatchAndWait.mockResolvedValueOnce({ + ...makeDeterministicSwarmResult(), + reply: null, + error: "agent runner failed", + }); + + const agents = makeAgents(); + const result = await runSingleAgent( + DEFAULT_CWD, + agents, + "worker", + "task", + undefined, + 1, + undefined, + undefined, + NOOP_MAKE_DETAILS, + undefined, + undefined, + ); + + assert.equal(result.exitCode, 1); + assert.ok(result.errorMessage?.includes("agent runner failed")); +}); + +test("onEvent forwards events to processSubagentEventLine via onUpdate callback", async () => { + process.env.SF_SUBAGENT_VIA_SWARM = "1"; + + // Capture the onEvent callback from the dispatch call and fire a message_end + // event through it; verify that the emitUpdate path fires with messages populated. + let capturedOnEvent = null; + swarmDispatchAndWait.mockImplementationOnce( + async (_basePath, _envelope, options) => { + capturedOnEvent = options.onEvent; + // Fire a synthetic message_end event through the callback + if (capturedOnEvent) { + capturedOnEvent({ + type: "message_end", + message: { + role: "assistant", + content: [{ type: "text", text: "hello from agent" }], + usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, cost: { total: 0 }, totalTokens: 30 }, + model: "claude-haiku-4.5", + stopReason: "stop", + }, + }); + } + return makeDeterministicSwarmResult({ reply: "hello from agent" }); + }, + ); + + let updateFired = false; + const onUpdate = () => { + updateFired = true; + }; + + const agents = makeAgents(); + const result = await runSingleAgent( + DEFAULT_CWD, + agents, + "worker", + "task", + undefined, + 1, + undefined, + onUpdate, + () => () => ({}), + undefined, + undefined, + ); + + // The message_end event should have been processed into currentResult.messages + assert.ok( + result.messages.some( + (m) => + m.role === "assistant" && + m.content?.some((c) => c.type === "text" && c.text === "hello from agent"), + ), + "message from onEvent should appear in result.messages", + ); + + assert.ok(updateFired, "onUpdate/emitUpdate should have been called"); + // usage should reflect the event-based counters + assert.equal(result.usage.input, 10); + assert.equal(result.usage.output, 20); + assert.equal(result.usage.turns, 1); +}); + +test("unknown agent → early return without touching swarm", async () => { + process.env.SF_SUBAGENT_VIA_SWARM = "1"; + + const agents = makeAgents(); + const result = await runSingleAgent( + DEFAULT_CWD, + agents, + "nonexistent-agent", + "task", + undefined, + 1, + undefined, + undefined, + NOOP_MAKE_DETAILS, + undefined, + undefined, + ); + + assert.equal(result.exitCode, 1); + assert.ok(result.stderr.includes("Unknown agent")); + assert.equal(swarmDispatchAndWait.mock.calls.length, 0); +}); + +test("cwd override is forwarded as basePath to swarmDispatchAndWait", async () => { + process.env.SF_SUBAGENT_VIA_SWARM = "1"; + swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult()); + + const agents = makeAgents(); + const customCwd = "/custom/project/path"; + await runSingleAgent( + DEFAULT_CWD, + agents, + "worker", + "task", + customCwd, // cwd override + 1, + undefined, + undefined, + NOOP_MAKE_DETAILS, + undefined, + undefined, + ); + + const [calledBasePath] = swarmDispatchAndWait.mock.calls[0]; + assert.equal(calledBasePath, customCwd); +}); + +test("AbortSignal is passed through to swarmDispatchAndWait options", async () => { + process.env.SF_SUBAGENT_VIA_SWARM = "1"; + swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult()); + + const controller = new AbortController(); + const agents = makeAgents(); + await runSingleAgent( + DEFAULT_CWD, + agents, + "worker", + "task", + undefined, + 1, + controller.signal, + undefined, + NOOP_MAKE_DETAILS, + undefined, + undefined, + ); + + const [, , calledOptions] = swarmDispatchAndWait.mock.calls[0]; + // The chained controller's signal is passed (not the raw caller signal, + // but it must be an AbortSignal instance). + assert.ok( + calledOptions.signal instanceof AbortSignal, + "signal must be an AbortSignal", + ); +});