import { describe, expect, test } from "bun:test"; import { mock } from "bun:test"; // Mock conversation-crud before importing tool executors that depend on it. mock.module("../persistence/conversation-crud.js", () => ({ setConversationProcessingStartedAt: () => {}, isConversationProcessing: () => false, setConversationOriginChannelIfUnset: () => {}, updateConversationContextWindow: () => {}, deleteMessageById: () => {}, updateConversationTitle: () => {}, updateConversationUsage: () => {}, addMessage: () => ({ id: "mock-msg-id" }), getConversation: () => ({ id: "conv-1", contextSummary: null, contextCompactedMessageCount: 0, totalInputTokens: 0, totalOutputTokens: 0, totalEstimatedCost: 0, title: null, }), provenanceFromTrustContext: () => ({ source: "user", trustContext: undefined, }), getConversationOriginInterface: () => null, getConversationOriginChannel: () => null, getMessages: () => null, createConversation: () => ({ id: "mock-conv" }), reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); import { clearConversations, setConversation, } from "../daemon/conversation-registry.js"; import type { Message } from "../providers/types.js"; import { getSubagentManager } from "../subagent/index.js"; import { SUBAGENT_ROLE_REGISTRY } from "../subagent/types.js"; import { executeSubagentSpawn } from "../tools/subagent/spawn.js"; // ── Shared helpers ────────────────────────────────────────────────── function makeContext( conversationId: string, extras: Record = {}, ) { return { workingDir: "/tmp", conversationId, trustClass: "guardian" as const, ...extras, } as import("../tools/types.js").ToolContext; } const FAKE_PARENT_MESSAGES: Message[] = [ { role: "user", content: [{ type: "text", text: "Hello from parent" }], }, { role: "assistant", content: [{ type: "text", text: "Hello! How can I help?" }], }, ]; describe("subagent_spawn fork parameter", () => { test("fork: true passes parent context to manager", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "fork-subagent-id"; }; // Populate the store with a fake parent conversation. clearConversations(); setConversation("parent-conv-1", { messages: FAKE_PARENT_MESSAGES, getCurrentSystemPrompt: () => "You are a helpful assistant.", } as any); try { const result = await executeSubagentSpawn( { label: "Fork task", objective: "Summarize our discussion", fork: true, }, makeContext("parent-conv-1", { sendToClient: () => {} }), ); expect(result.isError).toBe(false); expect(capturedConfig).toBeDefined(); expect(capturedConfig!.fork).toBe(true); expect(capturedConfig!.parentMessages).toEqual(FAKE_PARENT_MESSAGES); expect(capturedConfig!.parentSystemPrompt).toBe( "You are a helpful assistant.", ); expect(capturedConfig!.parentConversationId).toBe("parent-conv-1"); // Verify the response includes isFork const parsed = JSON.parse(result.content); expect(parsed.isFork).toBe(true); expect(parsed.subagentId).toBe("fork-subagent-id"); expect(parsed.status).toBe("pending"); expect(parsed.message).toContain("Forked subagent"); } finally { manager.spawn = originalSpawn; clearConversations(); } }); test.each(["researcher", "builder"])( "fork: true honors role %s", async (role) => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "fork-role-id"; }; clearConversations(); setConversation("parent-conv-role", { messages: FAKE_PARENT_MESSAGES, getCurrentSystemPrompt: () => "Parent prompt.", } as any); try { const result = await executeSubagentSpawn( { label: "Fork with role", objective: "Do something", fork: true, role, }, makeContext("parent-conv-role", { sendToClient: () => {} }), ); expect(result.isError).toBe(false); expect(capturedConfig).toBeDefined(); // The manager applies the type's allowlist to a fork like any other // spawn, so a read-only fork really is read-only. expect(capturedConfig!.role).toBe(role); expect(capturedConfig!.fork).toBe(true); expect(JSON.parse(result.content).role).toBe(role); } finally { manager.spawn = originalSpawn; clearConversations(); } }, ); test("a fork that names no role keeps the parent's tool surface", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "fork-no-role-id"; }; clearConversations(); setConversation("parent-conv-no-role", { messages: FAKE_PARENT_MESSAGES, getCurrentSystemPrompt: () => "Parent prompt.", } as any); try { const result = await executeSubagentSpawn( { label: "Plain fork", objective: "Do something", fork: true, }, makeContext("parent-conv-no-role", { sendToClient: () => {} }), ); expect(result.isError).toBe(false); // No role resolves to builder, which declares no allowlist, so the fork // keeps the full surface described by the system prompt it inherits. expect(capturedConfig!.role).toBe("builder"); expect(SUBAGENT_ROLE_REGISTRY.builder.allowedTools).toBeUndefined(); expect(JSON.parse(result.content).role).toBe("builder"); } finally { manager.spawn = originalSpawn; clearConversations(); } }); test("fork: true defaults sendResultToUser to false", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "fork-silent-id"; }; clearConversations(); setConversation("parent-conv-silent", { messages: FAKE_PARENT_MESSAGES, getCurrentSystemPrompt: () => "Parent prompt.", } as any); try { // No send_result_to_user specified — fork should default to false const result = await executeSubagentSpawn( { label: "Silent fork", objective: "Internal processing", fork: true, }, makeContext("parent-conv-silent", { sendToClient: () => {} }), ); expect(result.isError).toBe(false); expect(capturedConfig).toBeDefined(); expect(capturedConfig!.sendResultToUser).toBe(false); } finally { manager.spawn = originalSpawn; clearConversations(); } }); test("fork: true with explicit send_result_to_user: true preserves it", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "fork-visible-id"; }; clearConversations(); setConversation("parent-conv-visible", { messages: FAKE_PARENT_MESSAGES, getCurrentSystemPrompt: () => "Parent prompt.", } as any); try { const result = await executeSubagentSpawn( { label: "Visible fork", objective: "Share with user", fork: true, send_result_to_user: true, }, makeContext("parent-conv-visible", { sendToClient: () => {} }), ); expect(result.isError).toBe(false); expect(capturedConfig).toBeDefined(); expect(capturedConfig!.sendResultToUser).toBe(true); } finally { manager.spawn = originalSpawn; clearConversations(); } }); test("fork: false / omitted behaves identically to current behavior", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); // Test with fork: false let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "regular-subagent-id"; }; try { const result = await executeSubagentSpawn( { label: "Regular task", objective: "Do something", fork: false, role: "researcher", context: "Some context", }, makeContext("regular-conv-1", { sendToClient: () => {} }), ); expect(result.isError).toBe(false); expect(capturedConfig).toBeDefined(); // Should NOT have fork fields expect(capturedConfig!.fork).toBeUndefined(); expect(capturedConfig!.parentMessages).toBeUndefined(); expect(capturedConfig!.parentSystemPrompt).toBeUndefined(); // Should have role expect(capturedConfig!.role).toBe("researcher"); // Should have regular sendResultToUser default (true) expect(capturedConfig!.sendResultToUser).toBe(true); expect(capturedConfig!.context).toBe("Some context"); // Response should NOT include isFork const parsed = JSON.parse(result.content); expect(parsed.isFork).toBeUndefined(); expect(parsed.message).toContain("spawned"); expect(parsed.message).not.toContain("Forked"); } finally { manager.spawn = originalSpawn; } }); test("fork omitted behaves like fork: false", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "omitted-fork-id"; }; try { const result = await executeSubagentSpawn( { label: "No fork field", objective: "Standard task", }, makeContext("no-fork-conv", { sendToClient: () => {} }), ); expect(result.isError).toBe(false); expect(capturedConfig).toBeDefined(); expect(capturedConfig!.fork).toBeUndefined(); expect(capturedConfig!.parentMessages).toBeUndefined(); expect(capturedConfig!.parentSystemPrompt).toBeUndefined(); expect(capturedConfig!.sendResultToUser).toBe(true); const parsed = JSON.parse(result.content); expect(parsed.isFork).toBeUndefined(); } finally { manager.spawn = originalSpawn; } }); // Spawn mode is what makes delegated LLM spend separable: every variety // emits under `llm_call_site = "subagentSpawn"`, so the mode declared here // is the only thing distinguishing a fresh spawn from a context-inheriting // fork downstream. test("declares spawnMode 'fork' for a forked spawn", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "fork-mode-id"; }; clearConversations(); setConversation("spawn-mode-parent", { messages: FAKE_PARENT_MESSAGES, getCurrentSystemPrompt: () => "You are a helpful assistant.", } as any); try { await executeSubagentSpawn( { label: "Forked", objective: "Continue", fork: true }, makeContext("spawn-mode-parent", { sendToClient: () => {} }), ); expect(capturedConfig!.spawnMode).toBe("fork"); } finally { manager.spawn = originalSpawn; clearConversations(); } }); test("declares spawnMode 'regular' for a non-forked spawn", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "regular-mode-id"; }; try { await executeSubagentSpawn( { label: "Plain", objective: "Do work", role: "researcher" }, makeContext("spawn-mode-conv", { sendToClient: () => {} }), ); expect(capturedConfig!.spawnMode).toBe("regular"); // Role and spawn mode are orthogonal dimensions, both are recorded. expect(capturedConfig!.role).toBe("researcher"); } finally { manager.spawn = originalSpawn; } }); test("error when parent conversation cannot be resolved", async () => { // Empty store — findConversation will return undefined. clearConversations(); const result = await executeSubagentSpawn( { label: "Orphan fork", objective: "Should fail", fork: true, }, makeContext("nonexistent-parent", { sendToClient: () => {} }), ); expect(result.isError).toBe(true); expect(result.content).toContain("Cannot fork"); expect(result.content).toContain( "parent conversation could not be resolved", ); }); test("threads context.toolUseId into config as parentToolUseId", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "tool-use-id-subagent"; }; try { const result = await executeSubagentSpawn( { label: "Stamped task", objective: "Do something", }, makeContext("tool-use-conv", { sendToClient: () => {}, toolUseId: "toolu_123", }), ); expect(result.isError).toBe(false); expect(capturedConfig).toBeDefined(); expect(capturedConfig!.parentToolUseId).toBe("toolu_123"); } finally { manager.spawn = originalSpawn; } }); test("omits parentToolUseId when no toolUseId is in context", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "no-tool-use-id-subagent"; }; try { const result = await executeSubagentSpawn( { label: "Unstamped task", objective: "Do something", }, makeContext("no-tool-use-conv", { sendToClient: () => {} }), ); expect(result.isError).toBe(false); expect(capturedConfig).toBeDefined(); expect(capturedConfig!.parentToolUseId).toBeUndefined(); } finally { manager.spawn = originalSpawn; } }); test("fork: true shallow copies parent messages", async () => { const manager = getSubagentManager(); const originalSpawn = manager.spawn.bind(manager); const originalMessages = [...FAKE_PARENT_MESSAGES]; let capturedConfig: Record | undefined; manager.spawn = async (config: Record) => { capturedConfig = config; return "copy-check-id"; }; clearConversations(); setConversation("parent-conv-copy", { messages: originalMessages, getCurrentSystemPrompt: () => "Prompt.", } as any); try { await executeSubagentSpawn( { label: "Copy check", objective: "Test", fork: true, }, makeContext("parent-conv-copy", { sendToClient: () => {} }), ); expect(capturedConfig).toBeDefined(); const passedMessages = capturedConfig!.parentMessages as Message[]; // Should be a different array reference (shallow copy via spread) expect(passedMessages).not.toBe(originalMessages); // But same content expect(passedMessages).toEqual(originalMessages); } finally { manager.spawn = originalSpawn; clearConversations(); } }); });