/** * Tests for the toolsDisabledDepth mechanism in createResolveToolsCallback. * * Covers: * - Resolver returns empty tools when toolsDisabledDepth > 0 * - Resolver returns normal tools when toolsDisabledDepth is back to 0 * - allowedToolNames is cleared while disabled and restored on next normal call * - Depth counter survives overlapping increments/decrements */ import { describe, expect, mock, test } from "bun:test"; import type { SkillProjectionCache } from "../daemon/conversation-skill-tools.js"; import type { Message, ToolDefinition } from "../providers/types.js"; // --------------------------------------------------------------------------- // Mocks — must be set up before importing the module under test // --------------------------------------------------------------------------- mock.module("../daemon/conversation-skill-tools.js", () => ({ projectSkillTools: mock((_history: Message[], _opts: unknown) => ({ allowedToolNames: new Set(), toolDefinitions: [], })), })); // --------------------------------------------------------------------------- // Import after mocks // --------------------------------------------------------------------------- import type { Conversation } from "../daemon/conversation.js"; import { createResolveToolsCallback } from "../daemon/conversation-tool-setup.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeToolDef(name: string): ToolDefinition { return { name, description: `${name} tool`, input_schema: {} }; } function makeCtx(overrides: Partial = {}): Conversation { return { skillProjectionState: new Map(), skillProjectionCache: {} as SkillProjectionCache, toolsDisabledDepth: 0, ...overrides, } as unknown as Conversation; } const EMPTY_HISTORY: Message[] = []; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("createResolveToolsCallback — toolsDisabledDepth", () => { test("returns undefined when no tool definitions provided", () => { const ctx = makeCtx(); const resolve = createResolveToolsCallback([], ctx); expect(resolve).toBeUndefined(); }); test("returns normal tools when toolsDisabledDepth is 0", () => { const toolDefs = [makeToolDef("tool_a"), makeToolDef("tool_b")]; const ctx = makeCtx(); const resolve = createResolveToolsCallback(toolDefs, ctx)!; const tools = resolve(EMPTY_HISTORY); expect(tools.length).toBeGreaterThanOrEqual(2); expect(tools.map((t) => t.name)).toContain("tool_a"); expect(tools.map((t) => t.name)).toContain("tool_b"); expect(ctx.allowedToolNames?.size).toBeGreaterThan(0); }); test("returns empty tools when toolsDisabledDepth > 0", () => { const toolDefs = [makeToolDef("tool_a"), makeToolDef("tool_b")]; const ctx = makeCtx({ toolsDisabledDepth: 1 }); const resolve = createResolveToolsCallback(toolDefs, ctx)!; const tools = resolve(EMPTY_HISTORY); expect(tools).toEqual([]); expect(ctx.allowedToolNames).toEqual(new Set()); }); test("returns empty tools when toolsDisabledDepth is > 1 (overlapping callers)", () => { const toolDefs = [makeToolDef("tool_a")]; const ctx = makeCtx({ toolsDisabledDepth: 3 }); const resolve = createResolveToolsCallback(toolDefs, ctx)!; const tools = resolve(EMPTY_HISTORY); expect(tools).toEqual([]); expect(ctx.allowedToolNames).toEqual(new Set()); }); test("restores normal tools after depth returns to 0", () => { const toolDefs = [makeToolDef("tool_a"), makeToolDef("tool_b")]; const ctx = makeCtx({ toolsDisabledDepth: 0 }); const resolve = createResolveToolsCallback(toolDefs, ctx)!; // First call: normal let tools = resolve(EMPTY_HISTORY); expect(tools.length).toBeGreaterThanOrEqual(2); // Simulate pointer processor incrementing depth ctx.toolsDisabledDepth++; tools = resolve(EMPTY_HISTORY); expect(tools).toEqual([]); expect(ctx.allowedToolNames).toEqual(new Set()); // Simulate pointer processor decrementing depth (back to 0) ctx.toolsDisabledDepth--; tools = resolve(EMPTY_HISTORY); expect(tools.length).toBeGreaterThanOrEqual(2); expect(ctx.allowedToolNames!.has("tool_a")).toBe(true); expect(ctx.allowedToolNames!.has("tool_b")).toBe(true); }); test("overlapping increments keep tools disabled until all decremented", () => { const toolDefs = [makeToolDef("tool_a")]; const ctx = makeCtx(); const resolve = createResolveToolsCallback(toolDefs, ctx)!; // Two overlapping pointer requests ctx.toolsDisabledDepth++; ctx.toolsDisabledDepth++; expect(resolve(EMPTY_HISTORY)).toEqual([]); // First one finishes ctx.toolsDisabledDepth--; expect(ctx.toolsDisabledDepth).toBe(1); expect(resolve(EMPTY_HISTORY)).toEqual([]); // Second one finishes ctx.toolsDisabledDepth--; expect(ctx.toolsDisabledDepth).toBe(0); const tools = resolve(EMPTY_HISTORY); expect(tools.length).toBeGreaterThanOrEqual(1); }); test("clears allowedToolNames on every disabled call", () => { const toolDefs = [makeToolDef("tool_a")]; const ctx = makeCtx({ toolsDisabledDepth: 1 }); const resolve = createResolveToolsCallback(toolDefs, ctx)!; // Pre-populate allowedToolNames as if a previous normal turn set them ctx.allowedToolNames = new Set(["tool_a", "skill_x"]); resolve(EMPTY_HISTORY); expect(ctx.allowedToolNames).toEqual(new Set()); }); test("records the resolved set on registeredToolDefinitions for inventory queries", () => { // GIVEN a normal resolver call const toolDefs = [makeToolDef("tool_a"), makeToolDef("tool_b")]; const ctx = makeCtx(); const resolve = createResolveToolsCallback(toolDefs, ctx)!; // WHEN tools resolve normally resolve(EMPTY_HISTORY); // THEN the durable snapshot mirrors the per-turn gate const resolvedNames = new Set( ctx.registeredToolDefinitions?.map((d) => d.name), ); expect(resolvedNames).toEqual(ctx.allowedToolNames!); expect(resolvedNames.has("tool_a")).toBe(true); expect(resolvedNames.has("tool_b")).toBe(true); }); test("preserves registeredToolDefinitions when a later call disables tools", () => { // GIVEN a conversation that resolved tools on a normal turn const toolDefs = [makeToolDef("tool_a"), makeToolDef("tool_b")]; const ctx = makeCtx(); const resolve = createResolveToolsCallback(toolDefs, ctx)!; resolve(EMPTY_HISTORY); const snapshot = ctx.registeredToolDefinitions; // WHEN a subsequent call disables tools (e.g. pointer generation, or the // turn-teardown that clears the per-turn gate) ctx.toolsDisabledDepth = 1; resolve(EMPTY_HISTORY); // THEN the per-turn gate is empty but the inventory snapshot survives expect(ctx.allowedToolNames).toEqual(new Set()); expect(ctx.registeredToolDefinitions).toBe(snapshot); expect( ctx.registeredToolDefinitions?.some((d) => d.name === "tool_a"), ).toBe(true); }); });