import { describe, expect, mock, test } from "bun:test"; // ── Module mocks (must come before any imports that transitively load these) ── // 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" })), })); /** * Captured messages from injectMessageIntoParent → findConversation → enqueueMessage. * Each test can read this after triggering a notification. */ const capturedMessages: string[] = []; /** Parent conversation ids that a notification was routed to (findConversation). */ const capturedParentIds: string[] = []; // Live subagent conversations, keyed by conversationId. notifyParentFromChild // routes to the parent recorded here (the non-writable in-process source), so // tests register a child before expecting a notification to route. const liveSubagents = new Map< string, { parentConversationId: string; subagentSuppressParentNotifications?: boolean; } >(); mock.module("../daemon/conversation-registry.js", () => ({ findConversation: (id: string) => { capturedParentIds.push(id); return { enqueueMessage: (options: { content: string }) => { capturedMessages.push(options.content); return { queued: true }; }, persistUserMessage: async () => ({ id: "mock-msg", deduplicated: false }), runAgentLoop: async () => {}, }; }, findConversationOrSubagent: (id: string) => { const live = liveSubagents.get(id); return live ? { ...live } : undefined; }, })); // notifyParentFromChild reads cosmetic label/fork/objective from the durable // record. Routing does NOT come from here (see liveSubagents above), so a // tampered record can only mislabel, never redirect. const records = new Map(); mock.module("../persistence/subagent-store.js", () => ({ getSubagentRecordByConversationId: (conversationId: string) => records.get(conversationId), })); mock.module("../runtime/assistant-event-hub.js", () => ({ broadcastMessage: () => {}, })); import type { Conversation } from "../daemon/conversation.js"; import { isToolActiveForContext } from "../daemon/conversation-tool-setup.js"; import type { SubagentRecord } from "../persistence/subagent-store.js"; import { notifyParentFromChild } from "../subagent/notify.js"; import { executeSubagentNotifyParent, notifyParentTool, } from "../tools/subagent/notify-parent.js"; // ── Shared helpers ────────────────────────────────────────────────── /** * Register a subagent so `notifyParentFromChild` (and the `notify_parent` tool) * treat `conversationId` as a live subagent: a live child conversation (the * routing source) plus a durable record (cosmetic label/fork/objective). * Defaults to a running general subagent; pass overrides for status, label, * fork, etc. By default the live parent matches the record's parent; pass * `liveParentConversationId` to diverge them (models a tampered record). */ function seedSubagent( conversationId: string, overrides: Partial = {}, liveParentConversationId?: string, ): void { const record: SubagentRecord = { id: `sub-${conversationId}`, parentConversationId: `parent-${conversationId}`, conversationId, label: "Test", objective: "test", role: "builder", isFork: false, sendResultToUser: null, parentToolUseId: null, status: "running", error: null, createdAt: 0, startedAt: null, completedAt: null, inputTokens: 0, outputTokens: 0, estimatedCost: 0, ...overrides, }; records.set(conversationId, record); liveSubagents.set(conversationId, { parentConversationId: liveParentConversationId ?? record.parentConversationId, }); } function makeContext( conversationId: string, extras: Record = {}, ) { return { workingDir: "/tmp", conversationId, trustClass: "guardian" as const, ...extras, } as import("../tools/types.js").ToolContext; } /** Drain capturedMessages and return the latest one. */ function lastCapturedMessage(): string { return capturedMessages[capturedMessages.length - 1] ?? ""; } function clearCaptured(): void { capturedMessages.length = 0; } // ── Tool definition ──────────────────────────────────────────────── describe("notify_parent tool definition", () => { test("has correct core tool definition", () => { const def = notifyParentTool; const schema = def.input_schema as Record; expect(def.name).toBe("notify_parent"); expect(schema.required).toContain("message"); expect( (schema.properties as Record>).urgency .enum, ).toEqual(["info", "important", "blocked"]); expect(notifyParentTool.category).toBe("orchestration"); }); test("is hidden from non-subagent context", () => { const ctx = { isSubagent: false, preactivatedSkillIds: [], skillProjectionState: new Map(), skillProjectionCache: new Map(), toolsDisabledDepth: 0, } as unknown as Conversation; expect(isToolActiveForContext("notify_parent", ctx)).toBe(false); }); test("is hidden when isSubagent is undefined", () => { const ctx = { preactivatedSkillIds: [], skillProjectionState: new Map(), skillProjectionCache: new Map(), toolsDisabledDepth: 0, } as unknown as Conversation; expect(isToolActiveForContext("notify_parent", ctx)).toBe(false); }); test("is visible to subagent context", () => { const ctx = { isSubagent: true, preactivatedSkillIds: [], skillProjectionState: new Map(), skillProjectionCache: new Map(), toolsDisabledDepth: 0, } as unknown as Conversation; expect(isToolActiveForContext("notify_parent", ctx)).toBe(true); }); }); // ── executeSubagentNotifyParent ──────────────────────────────────── describe("executeSubagentNotifyParent", () => { test("rejects calls from non-subagent conversations", async () => { const result = await executeSubagentNotifyParent( { message: "Found something important" }, makeContext("not-a-subagent-conv"), ); expect(result.isError).toBe(true); expect(result.content).toContain("Could not notify parent"); expect(result.content).toContain("only available to subagents"); }); test("succeeds when called from a subagent conversation", async () => { clearCaptured(); const conversationId = "conv-notify-sub-1"; seedSubagent(conversationId); const result = await executeSubagentNotifyParent( { message: "Found key results", urgency: "important" }, makeContext(conversationId), ); expect(result.isError).toBe(false); const parsed = JSON.parse(result.content); expect(parsed.sent).toBe(true); expect(parsed.urgency).toBe("important"); expect(lastCapturedMessage()).toContain("Found key results"); }); test("formats message with label and urgency", async () => { clearCaptured(); const conversationId = "conv-notify-format-1"; seedSubagent(conversationId, { label: "Research Task", objective: "research", }); await executeSubagentNotifyParent( { message: "Preliminary findings ready", urgency: "info" }, makeContext(conversationId), ); expect(lastCapturedMessage()).toBe( '[Subagent "Research Task" — info] Preliminary findings ready', ); }); test("returns error when message is empty", async () => { const result = await executeSubagentNotifyParent( { message: "" }, makeContext("some-conv"), ); expect(result.isError).toBe(true); expect(result.content).toContain('"message" is required'); }); test("returns error when message is missing", async () => { const result = await executeSubagentNotifyParent( {}, makeContext("some-conv"), ); expect(result.isError).toBe(true); expect(result.content).toContain('"message" is required'); }); test("defaults urgency to info when not provided", async () => { const conversationId = "conv-notify-default-urg-1"; seedSubagent(conversationId); const result = await executeSubagentNotifyParent( { message: "Progress update" }, makeContext(conversationId), ); expect(result.isError).toBe(false); const parsed = JSON.parse(result.content); expect(parsed.urgency).toBe("info"); }); test("appends guidance hint for blocked urgency", async () => { clearCaptured(); const conversationId = "conv-notify-blocked-1"; seedSubagent(conversationId); await executeSubagentNotifyParent( { message: "Need API key to proceed", urgency: "blocked" }, makeContext(conversationId), ); expect(lastCapturedMessage()).toContain("Need API key to proceed"); expect(lastCapturedMessage()).toContain( "Use subagent_message to send guidance to this subagent.", ); }); }); // ── notifyParentFromChild ────────────────────────────────────────── describe("notifyParentFromChild", () => { test("returns false when the conversation is not a subagent", () => { expect(notifyParentFromChild("unknown-conversation", "hi", "info")).toBe( false, ); }); test("returns false for terminal subagents", () => { for (const status of ["completed", "failed", "aborted"] as const) { const conversationId = `conv-terminal-${status}`; seedSubagent(conversationId, { status }); expect( notifyParentFromChild(conversationId, "Should not arrive", "info"), ).toBe(false); } }); test("returns true for a running subagent and injects into the parent", () => { clearCaptured(); const conversationId = "conv-running-1"; seedSubagent(conversationId); expect(notifyParentFromChild(conversationId, "Test message", "info")).toBe( true, ); expect(lastCapturedMessage()).toContain("Test message"); }); test("returns false for a synchronous child that suppresses parent notifications", () => { clearCaptured(); const conversationId = "conv-suppressed-1"; seedSubagent(conversationId); // spawnAndAwait children carry this flag: the awaiting caller is their // only parent channel, so a mid-run injection must not reach the parent. const live = liveSubagents.get(conversationId); if (live) { live.subagentSuppressParentNotifications = true; } expect( notifyParentFromChild(conversationId, "Should not arrive", "info"), ).toBe(false); expect(capturedMessages).toHaveLength(0); }); test("labels forks as Fork", () => { clearCaptured(); const conversationId = "conv-fork-1"; seedSubagent(conversationId, { isFork: true, label: "Explore" }); notifyParentFromChild(conversationId, "branch result", "info"); expect(lastCapturedMessage()).toBe('[Fork "Explore" — info] branch result'); }); test("routes to the live parent, ignoring a tampered record parent", () => { clearCaptured(); capturedParentIds.length = 0; const conversationId = "conv-tamper-1"; // The durable record claims a victim conversation as parent; the live // child's real parent differs. Routing must follow the live child. seedSubagent( conversationId, { parentConversationId: "victim-conversation" }, "real-parent-conversation", ); expect(notifyParentFromChild(conversationId, "injected", "info")).toBe( true, ); expect(capturedParentIds).toContain("real-parent-conversation"); expect(capturedParentIds).not.toContain("victim-conversation"); }); }); describe("notify_parent — model-input schema validation (LUM-2857)", () => { test("rejects a non-string message", async () => { const result = await executeSubagentNotifyParent( { message: 42 }, makeContext("conv-notify-schema-1"), ); expect(result.isError).toBe(true); expect(result.content).toContain('Invalid input for tool "notify_parent"'); }); test("degrades a malformed urgency to info instead of forwarding it", async () => { const conversationId = "conv-notify-schema-2"; seedSubagent(conversationId); const result = await executeSubagentNotifyParent( { message: "found something", urgency: 42 }, makeContext(conversationId), ); expect(result.isError).toBe(false); const parsed = JSON.parse(result.content) as { urgency: string }; expect(parsed.urgency).toBe("info"); }); });