// Common parameter tests cover shared action gates and typed argument readers // used by channel/tool adapters. import { describe, expect, it } from "vitest"; import { createActionGate, readFiniteNumberParam, readNonNegativeIntegerParam, readPositiveIntegerParam, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, ToolInputError, } from "./common.js"; type TestActions = { reactions?: boolean; messages?: boolean; }; describe("createActionGate", () => { it("defaults to enabled when unset", () => { const gate = createActionGate(undefined); expect(gate("reactions")).toBe(true); expect(gate("messages", false)).toBe(false); }); it("respects explicit false", () => { const gate = createActionGate({ reactions: false }); expect(gate("reactions")).toBe(false); expect(gate("messages")).toBe(true); }); }); describe("readStringOrNumberParam", () => { it("returns numeric strings for numbers", () => { const params = { chatId: 123 }; expect(readStringOrNumberParam(params, "chatId")).toBe("123"); }); it("trims strings", () => { const params = { chatId: " abc " }; expect(readStringOrNumberParam(params, "chatId")).toBe("abc"); }); }); describe("readStringArrayParam", () => { it.each([ { value: " alpha ", expected: ["alpha"] }, { value: [" beta ", "", 7, null, "alpha", "beta"], expected: ["beta", "alpha", "beta"] }, { value: [], expected: undefined }, { value: [" ", false, {}], expected: undefined }, { value: " ", expected: undefined }, { value: undefined, expected: undefined }, { value: null, expected: undefined }, { value: 7, expected: undefined }, ])("normalizes $value without coercing nonstrings", ({ value, expected }) => { expect(readStringArrayParam({ itemIds: value }, "itemIds")).toEqual(expected); }); it("preserves direct-key precedence over snake-case aliases", () => { expect(readStringArrayParam({ item_ids: [" first ", "second"] }, "itemIds")).toEqual([ "first", "second", ]); expect(readStringArrayParam({ itemIds: ["direct"], item_ids: ["alias"] }, "itemIds")).toEqual([ "direct", ]); expect( readStringArrayParam({ itemIds: undefined, item_ids: ["alias"] }, "itemIds"), ).toBeUndefined(); }); it("keeps required errors and custom labels after normalization", () => { expect(() => readStringArrayParam({}, "itemIds", { required: true })).toThrow( new ToolInputError("itemIds required"), ); expect(() => readStringArrayParam({ itemIds: [" ", 7] }, "itemIds", { required: true, label: "items", }), ).toThrow(new ToolInputError("items required")); expect(readStringArrayParam({ itemIds: " first " }, "itemIds", { required: true })).toEqual([ "first", ]); }); it("always trims and drops blanks despite scalar-string options", () => { expect( readStringArrayParam({ itemIds: [" first ", " "] }, "itemIds", { trim: false, allowEmpty: true, }), ).toEqual(["first"]); }); }); describe("readNumberParam", () => { it("parses numeric strings", () => { const params = { messageId: "42" }; expect(readNumberParam(params, "messageId")).toBe(42); }); it("keeps partial parse behavior by default", () => { // Some legacy channel tools pass identifiers with numeric prefixes; strict // parsing is opt-in for new bounded fields. const params = { messageId: "42abc" }; expect(readNumberParam(params, "messageId")).toBe(42); }); it("rejects partial numeric strings when strict is enabled", () => { const params = { messageId: "42abc" }; expect(readNumberParam(params, "messageId", { strict: true })).toBeUndefined(); }); it("truncates when integer is true", () => { const params = { messageId: "42.9" }; expect(readNumberParam(params, "messageId", { integer: true })).toBe(42); }); it("accepts only positive safe integers when positiveInteger is true", () => { expect(readNumberParam({ tokenBudget: "42" }, "tokenBudget", { positiveInteger: true })).toBe( 42, ); expect( readNumberParam({ tokenBudget: "42.9" }, "tokenBudget", { positiveInteger: true }), ).toBeUndefined(); expect( readNumberParam({ tokenBudget: 0 }, "tokenBudget", { positiveInteger: true }), ).toBeUndefined(); expect( readNumberParam({ tokenBudget: Number.POSITIVE_INFINITY }, "tokenBudget", { positiveInteger: true, }), ).toBeUndefined(); }); it("accepts only nonnegative safe integers when nonNegativeInteger is true", () => { expect(readNumberParam({ cacheAge: 0 }, "cacheAge", { nonNegativeInteger: true })).toBe(0); expect(readNumberParam({ cacheAge: "42" }, "cacheAge", { nonNegativeInteger: true })).toBe(42); expect( readNumberParam({ cacheAge: "42.9" }, "cacheAge", { nonNegativeInteger: true }), ).toBeUndefined(); expect( readNumberParam({ cacheAge: -1 }, "cacheAge", { nonNegativeInteger: true }), ).toBeUndefined(); expect( readNumberParam({ cacheAge: Number.POSITIVE_INFINITY }, "cacheAge", { nonNegativeInteger: true, }), ).toBeUndefined(); }); it("throws for invalid present positive integer params", () => { expect(readPositiveIntegerParam({ timeoutMs: "42" }, "timeoutMs")).toBe(42); expect(readPositiveIntegerParam({ timeoutMs: null }, "timeoutMs")).toBeUndefined(); expect(() => readPositiveIntegerParam({ timeoutMs: "42.5" }, "timeoutMs")).toThrow( "timeoutMs must be a positive integer", ); expect(() => readPositiveIntegerParam({ timeoutMs: 0 }, "timeoutMs", { message: "timeoutMs must be a positive integer in milliseconds.", }), ).toThrow("timeoutMs must be a positive integer in milliseconds."); expect(() => readPositiveIntegerParam({ maxResults: 21 }, "maxResults", { max: 20, message: "maxResults must be an integer from 1 to 20", }), ).toThrow("maxResults must be an integer from 1 to 20"); }); it("throws for invalid present non-negative integer params", () => { expect(readNonNegativeIntegerParam({ position: 0 }, "position")).toBe(0); expect(readNonNegativeIntegerParam({ position: "42" }, "position")).toBe(42); expect(readNonNegativeIntegerParam({ position: null }, "position")).toBeUndefined(); expect(() => readNonNegativeIntegerParam({ position: "4.5" }, "position")).toThrow( "position must be a non-negative integer", ); expect(() => readNonNegativeIntegerParam({ deleteDays: 8 }, "deleteDays", { max: 7, message: "deleteDays must be an integer from 0 to 7", }), ).toThrow("deleteDays must be an integer from 0 to 7"); }); it("treats empty or whitespace-only strings as unset for optional positive integer params", () => { // Tool-calling models routinely emit empty-string defaults for optional // params (e.g. Telegram replyTo/threadId) they are not actually setting. // An empty/whitespace string carries no value and must not throw. expect(readPositiveIntegerParam({ replyTo: "" }, "replyTo")).toBeUndefined(); expect(readPositiveIntegerParam({ threadId: " " }, "threadId")).toBeUndefined(); expect(readPositiveIntegerParam({ replyTo: "\t\n" }, "replyTo")).toBeUndefined(); // Genuinely invalid present values must still throw. expect(() => readPositiveIntegerParam({ replyTo: "0" }, "replyTo")).toThrow( "replyTo must be a positive integer", ); expect(() => readPositiveIntegerParam({ replyTo: 0 }, "replyTo")).toThrow( "replyTo must be a positive integer", ); expect(() => readPositiveIntegerParam({ replyTo: "-3" }, "replyTo")).toThrow( "replyTo must be a positive integer", ); }); it("treats empty or whitespace-only strings as unset for optional non-negative integer params", () => { expect(readNonNegativeIntegerParam({ position: "" }, "position")).toBeUndefined(); expect(readNonNegativeIntegerParam({ position: " " }, "position")).toBeUndefined(); // A present, valid zero is still a real value. expect(readNonNegativeIntegerParam({ position: "0" }, "position")).toBe(0); expect(readNonNegativeIntegerParam({ position: 0 }, "position")).toBe(0); // Genuinely invalid present values must still throw. expect(() => readNonNegativeIntegerParam({ position: "4.5" }, "position")).toThrow( "position must be a non-negative integer", ); }); it("throws for invalid present bounded finite number params", () => { expect(readFiniteNumberParam({ quality: "0.75" }, "quality")).toBe(0.75); expect(readFiniteNumberParam({ quality: null }, "quality")).toBeUndefined(); expect(readFiniteNumberParam({ quality: "" }, "quality")).toBeUndefined(); expect(readFiniteNumberParam({ quality: " \t\n" }, "quality")).toBeUndefined(); expect(() => readFiniteNumberParam({ quality: "0.8jpg" }, "quality")).toThrow( "quality must be a finite number", ); expect(() => readFiniteNumberParam({ quality: 1.1 }, "quality", { min: 0, max: 1, message: "quality must be between 0 and 1", }), ).toThrow("quality must be between 0 and 1"); expect(() => readFiniteNumberParam({ fps: 0 }, "fps", { min: 0, minExclusive: true, message: "fps must be greater than 0", }), ).toThrow("fps must be greater than 0"); }); }); describe("snake_case aliases", () => { it.each([ { name: "string-or-number reader", read: () => readStringOrNumberParam({ chat_id: "123" }, "chatId"), expected: "123", }, { name: "number reader", read: () => readNumberParam({ message_id: "42" }, "messageId"), expected: 42, }, ])("accepts snake_case aliases for camelCase keys in $name", ({ read, expected }) => { expect(read()).toBe(expected); }); }); describe("required parameter validation", () => { it("throws when required values are missing", () => { expect(() => readStringOrNumberParam({}, "chatId", { required: true })).toThrow( /chatId required/, ); expect(() => readNumberParam({}, "messageId", { required: true })).toThrow( /messageId required/, ); }); }); describe("readReactionParams", () => { it("allows empty emoji for removal semantics", () => { const params = { emoji: "" }; const result = readReactionParams(params, { removeErrorMessage: "Emoji is required", }); expect(result.isEmpty).toBe(true); expect(result.remove).toBe(false); }); it("throws when remove true but emoji empty", () => { const params = { emoji: "", remove: true }; expect(() => readReactionParams(params, { removeErrorMessage: "Emoji is required", }), ).toThrow(/Emoji is required/); }); it("passes through remove flag", () => { const params = { emoji: "✅", remove: true }; const result = readReactionParams(params, { removeErrorMessage: "Emoji is required", }); expect(result.remove).toBe(true); expect(result.emoji).toBe("✅"); }); });