// Runtime plan tool tests cover schema normalization and diagnostics when the // runtime plan owns tool policy, with legacy provider fallback still available. import { expectDefined } from "@openclaw/normalization-core"; import type { AgentTool } from "openclaw/plugin-sdk/agent-core"; import { createNativeOpenAIResponsesModel, createParameterFreeTool, normalizedParameterFreeSchema, } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { PluginInstance } from "../../plugins/plugin-instance.js"; import { getPluginToolMeta, setPluginToolMeta } from "../../plugins/tool-metadata.js"; import { wrapToolWithAbortSignal } from "../agent-tools.abort.js"; import { isToolWrappedWithBeforeToolCallHook, wrapToolWithBeforeToolCallHook, } from "../agent-tools.before-tool-call.js"; import type { RuntimeToolSchemaDiagnostic } from "../tool-schema-projection.js"; import { clearToolSearchCatalog, createToolSearchCatalogRef, registerHeadlessToolSearchCatalog, } from "../tool-search-catalog.js"; import { getToolTerminalPresentation, setToolTerminalPresentation, } from "../tool-terminal-presentation.js"; import type { AnyAgentTool } from "../tools/common.js"; import { logAgentRuntimeToolDiagnostics, normalizeAgentRuntimeTools } from "./tools.js"; import type { AgentRuntimePlan } from "./types.js"; const mocks = vi.hoisted(() => ({ logProviderToolSchemaDiagnostics: vi.fn(), normalizeProviderToolSchemas: vi.fn(), })); vi.mock("../embedded-agent-runner/tool-schema-runtime.js", () => ({ logProviderToolSchemaDiagnostics: mocks.logProviderToolSchemaDiagnostics, normalizeProviderToolSchemas: mocks.normalizeProviderToolSchemas, })); describe("AgentRuntimePlan tool policy helpers", () => { beforeEach(() => { mocks.logProviderToolSchemaDiagnostics.mockReset(); mocks.normalizeProviderToolSchemas.mockReset(); }); it("uses RuntimePlan-owned tool normalization when a plan is available", () => { const tools = [createParameterFreeTool()] as AgentTool[]; const normalized = [{ ...tools[0], name: "normalized" }] as AgentTool[]; const model = createNativeOpenAIResponsesModel() as never; const normalize = vi.fn(() => normalized); const runtimePlan = { tools: { normalize, logDiagnostics: vi.fn(), }, } as unknown as AgentRuntimePlan; expect( normalizeAgentRuntimeTools({ runtimePlan, tools, provider: "openai", modelId: "gpt-5.4", modelApi: "openai-responses", workspaceDir: "/tmp/openclaw-runtime-plan-tools", model, }), ).toEqual(normalized); expect(normalize).toHaveBeenCalledWith(tools, { workspaceDir: "/tmp/openclaw-runtime-plan-tools", modelApi: "openai-responses", model, }); }); it("quarantines unreadable tools before RuntimePlan normalization", () => { // Broken plugin tool getters are removed before plan/provider normalization // so one bad tool cannot crash the full runtime tool list. const healthy = { ...createParameterFreeTool(), name: "healthy" } as AgentTool; const unreadable = { ...createParameterFreeTool(), name: "fuzzplugin_unreadable" } as AgentTool; Object.defineProperty(unreadable, "parameters", { enumerable: true, get() { throw new Error("fuzzplugin parameters getter exploded"); }, }); const tools = [unreadable, healthy]; const diagnostics: RuntimeToolSchemaDiagnostic[][] = []; const normalize = vi.fn((entries: AgentTool[]) => entries); const runtimePlan = { tools: { normalize, logDiagnostics: vi.fn(), }, } as unknown as AgentRuntimePlan; expect( normalizeAgentRuntimeTools({ runtimePlan, tools, provider: "openai", onPreNormalizationSchemaDiagnostics: (entries) => diagnostics.push([...entries]), }), ).toEqual([healthy]); expect(normalize).toHaveBeenCalledWith([healthy], { workspaceDir: undefined, modelApi: undefined, model: undefined, }); expect(diagnostics).toEqual([ [ { toolName: "fuzzplugin_unreadable", toolIndex: 0, violations: ["fuzzplugin_unreadable.parameters is unreadable"], }, ], ]); }); it("quarantines non-object schemas before provider schema normalization", () => { const healthy = { ...createParameterFreeTool(), name: "healthy" } as AgentTool; const arraySchema = { ...createParameterFreeTool("fuzzplugin_array_root"), parameters: { type: "array", items: { type: "number" } }, } as unknown as AgentTool; const diagnostics: RuntimeToolSchemaDiagnostic[][] = []; mocks.normalizeProviderToolSchemas.mockImplementationOnce(({ tools: entries }) => entries); expect( normalizeAgentRuntimeTools({ tools: [arraySchema, healthy], provider: "openai", onPreNormalizationSchemaDiagnostics: (entries) => diagnostics.push([...entries]), }), ).toEqual([healthy]); expect(mocks.normalizeProviderToolSchemas).toHaveBeenCalledWith( expect.objectContaining({ tools: [healthy], provider: "openai", }), ); expect(diagnostics).toEqual([ [ { toolName: "fuzzplugin_array_root", toolIndex: 0, violations: ['fuzzplugin_array_root.parameters.type must be "object"'], }, ], ]); }); it("accepts legacy optional model fields while normalizing RuntimePlan context", () => { const tools = [createParameterFreeTool()] as AgentTool[]; const normalize = vi.fn(() => tools); const runtimePlan = { tools: { normalize, logDiagnostics: vi.fn(), }, } as unknown as AgentRuntimePlan; expect( normalizeAgentRuntimeTools({ runtimePlan, tools, provider: "openai", modelApi: null, }), ).toEqual(tools); expect(normalize).toHaveBeenCalledWith(tools, { workspaceDir: undefined, modelApi: undefined, model: undefined, }); }); it("falls back to legacy provider schema normalization when no plan is available", () => { mocks.normalizeProviderToolSchemas.mockReturnValueOnce([ { ...createParameterFreeTool(), parameters: normalizedParameterFreeSchema(), }, ]); const normalized = normalizeAgentRuntimeTools({ tools: [createParameterFreeTool()] as AgentTool[], provider: "openai", modelId: "gpt-5.4", modelApi: "openai-responses", workspaceDir: "/tmp/openclaw-runtime-plan-tools", model: createNativeOpenAIResponsesModel() as never, }); expect(normalized[0]?.parameters).toEqual(normalizedParameterFreeSchema()); expect(mocks.normalizeProviderToolSchemas).toHaveBeenCalledTimes(1); expect(mocks.normalizeProviderToolSchemas.mock.calls.at(0)?.[0]).toEqual({ tools: [createParameterFreeTool()], provider: "openai", config: undefined, workspaceDir: "/tmp/openclaw-runtime-plan-tools", env: process.env, modelId: "gpt-5.4", modelApi: "openai-responses", model: createNativeOpenAIResponsesModel(), allowRuntimePluginLoad: undefined, }); }); it("does not load a provider runtime to normalize an empty tool set", () => { const onPreNormalizationSchemaDiagnostics = vi.fn(); expect( normalizeAgentRuntimeTools({ tools: [], provider: "openai", onPreNormalizationSchemaDiagnostics, }), ).toEqual([]); expect(onPreNormalizationSchemaDiagnostics).toHaveBeenCalledWith([], []); expect(mocks.normalizeProviderToolSchemas).not.toHaveBeenCalled(); }); it("preserves plugin metadata when provider schema normalization clones tools", () => { // Provider normalization may clone tool objects; plugin metadata has to move // with the clone so later dispatch still knows the owning plugin/MCP server. const tool = createParameterFreeTool("fixture__lookup_note") as AgentTool; const metadata: Parameters[1] = { pluginId: "bundle-mcp", kind: "memory", optional: true, replaySafe: true, sideEffecting: true, trustedLocalMedia: false, mcp: { serverName: "fixture", safeServerName: "fixture", toolName: "lookup_note", operation: "tool", deniedBySession: true, codexApproval: { mode: "prompt", annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, }, node: { id: "fixture-node", displayName: "Fixture node" }, }, }; setPluginToolMeta(tool, metadata); const normalized = { ...tool, parameters: normalizedParameterFreeSchema(), }; mocks.normalizeProviderToolSchemas.mockReturnValueOnce([normalized]); const result = normalizeAgentRuntimeTools({ tools: [tool], provider: "openai", }); expect(result[0]).toBe(normalized); expect(getPluginToolMeta(expectDefined(result[0], "result[0] test invariant"))).toBe(metadata); }); it.each(["plan", "provider"] as const)( "owns the assembly array after %s normalization while retaining plugin tool admission", async (route) => { const instance = new PluginInstance("normalizer-fixture"); const catalogRef = createToolSearchCatalogRef(); try { const output = { content: [{ type: "text" as const, text: "fixture note" }], details: {} }; const tool: AgentTool = { ...createParameterFreeTool("fixture__lookup_note"), label: "Fixture lookup", parameters: Type.Object({}), execute: vi.fn(async () => output), }; const metadata: Parameters[1] = { pluginId: "bundle-mcp", optional: false, mcp: { serverName: "fixture", safeServerName: "fixture", toolName: "lookup_note", operation: "tool", }, }; setPluginToolMeta(tool, metadata); // Even a pass-through provider hook returns an instance-owned collection view. const normalize = instance.wrap((tools: AgentTool[]) => tools); mocks.normalizeProviderToolSchemas.mockImplementationOnce(({ tools }) => normalize(tools)); const runtimePlan = route === "plan" ? ({ tools: { normalize, logDiagnostics: vi.fn() } } as unknown as AgentRuntimePlan) : undefined; const normalized = normalizeAgentRuntimeTools({ runtimePlan, tools: [tool], provider: "openai", }); const normalizedTool = expectDefined(normalized[0], "normalized tool"); const wrapped = normalized .map((entry) => wrapToolWithBeforeToolCallHook(entry)) .map((entry) => wrapToolWithAbortSignal(entry, new AbortController().signal)); const wrappedTool = expectDefined(wrapped[0], "wrapped tool"); expect(getPluginToolMeta(normalizedTool)).toBe(metadata); expect(getPluginToolMeta(wrappedTool)).toBe(metadata); registerHeadlessToolSearchCatalog({ catalogRef, tools: wrapped }); expect(catalogRef.current?.entries).toMatchObject([ { name: tool.name, source: "mcp", sourceName: "fixture", mcp: metadata.mcp }, ]); await expect(normalizedTool.execute("current-call", {})).resolves.toBe(output); await expect(wrappedTool.execute("wrapped-call", {})).resolves.toBe(output); await instance.dispose(); expect(() => normalizedTool.execute("retired-call", {})).toThrow( "Plugin normalizer-fixture was reloaded or disabled", ); await expect(wrappedTool.execute("retired-wrapped-call", {})).rejects.toThrow( "Plugin normalizer-fixture was reloaded or disabled", ); } finally { clearToolSearchCatalog({ catalogRef }); await instance.dispose(); } }, ); it.each([true, false, undefined])( "preserves output schemas and channel-progress visibility (%s) across runtime clones", (hideFromChannelProgress) => { const outputSchema = Type.Object( { id: Type.String(), ready: Type.Boolean() }, { additionalProperties: false }, ); const tool = { ...createParameterFreeTool("fixture_status"), outputSchema, } as unknown as AgentTool; Object.defineProperty(tool, "hideFromChannelProgress", { value: hideFromChannelProgress }); const normalized = { ...createParameterFreeTool("fixture_status"), parameters: normalizedParameterFreeSchema(), } as unknown as AgentTool; mocks.normalizeProviderToolSchemas.mockReturnValueOnce([normalized]); const result = normalizeAgentRuntimeTools({ tools: [tool], provider: "openai", }); expect(result[0]).toBe(normalized); expect(result[0]?.outputSchema).toBe(outputSchema); expect(result[0]?.hideFromChannelProgress).toBe( hideFromChannelProgress === true ? true : undefined, ); }, ); it("preserves private execution metadata when provider normalization clones tools", () => { const formatter = vi.fn(() => ({ text: "Terminal summary" })); const tool = { ...createParameterFreeTool("web_fetch"), label: "Web fetch", execute: vi.fn(), } as AgentTool; const source = setToolTerminalPresentation( wrapToolWithBeforeToolCallHook(tool, { agentId: "main", sessionId: "session-runtime-normalization", }), formatter, ); (source as AnyAgentTool).catalogMode = "direct-only"; const normalized = { ...createParameterFreeTool("web_fetch"), label: "Web fetch", execute: vi.fn(), parameters: normalizedParameterFreeSchema(), } as AgentTool; mocks.normalizeProviderToolSchemas.mockReturnValueOnce([normalized]); const result = normalizeAgentRuntimeTools({ tools: [source], provider: "openai", }); expect(result[0]).toBe(normalized); expect((result[0] as AnyAgentTool).catalogMode).toBe("direct-only"); expect( isToolWrappedWithBeforeToolCallHook(expectDefined(result[0], "result[0] test invariant")), ).toBe(true); expect(getToolTerminalPresentation(expectDefined(result[0], "result[0] test invariant"))).toBe( formatter, ); }); it("does not reread quarantined tools while preserving normalized metadata", () => { const unreadableName = { ...createParameterFreeTool("fuzzplugin_unreadable_name"), } as AgentTool; Object.defineProperty(unreadableName, "name", { enumerable: true, get() { throw new Error("fuzzplugin name getter exploded"); }, }); const healthy = createParameterFreeTool("fixture__lookup_note") as AgentTool; setPluginToolMeta(healthy, { pluginId: "bundle-mcp", optional: false, mcp: { serverName: "fixture", safeServerName: "fixture", toolName: "lookup_note", operation: "tool", }, }); const normalized = { ...healthy, parameters: normalizedParameterFreeSchema(), }; const diagnostics: RuntimeToolSchemaDiagnostic[][] = []; mocks.normalizeProviderToolSchemas.mockReturnValueOnce([normalized]); const result = normalizeAgentRuntimeTools({ tools: [unreadableName, healthy], provider: "openai", onPreNormalizationSchemaDiagnostics: (entries) => diagnostics.push([...entries]), }); expect(result).toEqual([normalized]); expect(getPluginToolMeta(expectDefined(result[0], "result[0] test invariant"))).toMatchObject({ pluginId: "bundle-mcp", mcp: { serverName: "fixture", toolName: "lookup_note", }, }); expect(diagnostics).toEqual([ [ { toolName: "tool[0]", toolIndex: 0, violations: ["tool[0].name is unreadable"], }, ], ]); }); it("quarantines unreadable tools before provider schema normalization", () => { const healthy = { ...createParameterFreeTool(), name: "healthy" } as AgentTool; const unreadable = { ...createParameterFreeTool(), name: "fuzzplugin_unreadable" } as AgentTool; Object.defineProperty(unreadable, "parameters", { enumerable: true, get() { throw new Error("fuzzplugin parameters getter exploded"); }, }); const tools = [unreadable, healthy]; const diagnostics: RuntimeToolSchemaDiagnostic[][] = []; mocks.normalizeProviderToolSchemas.mockImplementationOnce(({ tools: entries }) => entries); expect( normalizeAgentRuntimeTools({ tools, provider: "openai", onPreNormalizationSchemaDiagnostics: (entries) => diagnostics.push([...entries]), }), ).toEqual([healthy]); expect(mocks.normalizeProviderToolSchemas).toHaveBeenCalledWith( expect.objectContaining({ tools: [healthy], provider: "openai", }), ); expect(diagnostics).toEqual([ [ { toolName: "fuzzplugin_unreadable", toolIndex: 0, violations: ["fuzzplugin_unreadable.parameters is unreadable"], }, ], ]); }); it("can normalize without cold-loading provider runtime plugins", () => { const tools = [createParameterFreeTool()] as AgentTool[]; normalizeAgentRuntimeTools({ tools, provider: "openai", allowProviderRuntimePluginLoad: false, }); expect(mocks.normalizeProviderToolSchemas).toHaveBeenCalledWith( expect.objectContaining({ tools, provider: "openai", allowRuntimePluginLoad: false, }), ); }); it("routes diagnostics through RuntimePlan when a plan is available", () => { const tools = [createParameterFreeTool()] as AgentTool[]; const model = createNativeOpenAIResponsesModel() as never; const logDiagnostics = vi.fn(); const runtimePlan = { tools: { normalize: vi.fn(), logDiagnostics, }, } as unknown as AgentRuntimePlan; logAgentRuntimeToolDiagnostics({ runtimePlan, tools, provider: "openai", modelId: "gpt-5.4", modelApi: "openai-responses", workspaceDir: "/tmp/openclaw-runtime-plan-tools", model, }); expect(logDiagnostics).toHaveBeenCalledWith(tools, { workspaceDir: "/tmp/openclaw-runtime-plan-tools", modelApi: "openai-responses", model, }); }); });