import { describe, expect, it } from "vitest"; import type { ToolDefinition } from "../src/llm/client.js"; import { importSillyTavernPreset } from "../src/preset/importer.js"; import { clearToolPlanCache, decorateTools, normalizeToolCalls, resolveToolPlan, } from "../src/preset/tool-policy.js"; const submitTool: ToolDefinition = { type: "function", function: { name: "submit", description: "提交产物", parameters: { type: "object", properties: { content: { type: "string" }, outputTags: { type: "array", items: { type: "string" } }, }, required: ["content", "outputTags"], }, }, }; const readTool: ToolDefinition = { type: "function", function: { name: "read_blackboard", description: "读正文", parameters: { type: "object", properties: { tags: { type: "array" } }, required: ["tags"], }, }, }; function presetWithBindings() { return importSillyTavernPreset( { prompts: [ { identifier: "main", name: "main", role: "system", content: "主线" }, { identifier: "bound", name: "防截断2.0", role: "system", content: "所有输出必须调用 game_content 工具,放在 content 参数中", }, ], prompt_order: [ { character_id: 100001, order: [ { identifier: "main", enabled: true }, { identifier: "bound", enabled: true }, ], }, ], extensions: { SPreset: { ToolBindings: { bound: { enabled: true, form: { name: "game_content", description: "# 你的全部输出应当在此工具内。", parameters: [{ name: "content", type: "string", required: true }], }, }, }, }, }, }, { name: "Reborn-like" }, ).preset; } function presetWithPlaceholders() { return importSillyTavernPreset( { prompts: [ { identifier: "guide", name: "【ToolCall引导】工具说明", role: "system", content: "[Interface]\nactive_interface = %TOOL_NAME%\nstream_target = display_stream", }, ], prompt_order: [ { character_id: 100001, order: [{ identifier: "guide", enabled: true }], }, ], }, { name: "linux-like" }, ).preset; } describe("resolveToolPlan", () => { it("prefers SPreset.ToolBindings for the target tool and payload field", () => { const plan = resolveToolPlan(presetWithBindings()); expect(plan.present).toBe(true); expect(plan.targetName).toBe("game_content"); expect(plan.payloadFields).toEqual(["content"]); // 描述只取作者写在 ToolBindings 里的那句,不抄条目正文 expect(plan.description).toBe("# 你的全部输出应当在此工具内。"); }); it("falls back to placeholders when no binding exists", () => { const plan = resolveToolPlan(presetWithPlaceholders()); expect(plan.present).toBe(true); expect(plan.targetName).toBeNull(); expect(plan.payloadFields).toContain("display_stream"); }); it("is quiet when the preset has no tool protocol", () => { const preset = importSillyTavernPreset( { prompts: [{ identifier: "main", name: "main", role: "system", content: "只写正文" }], prompt_order: [ { character_id: 100001, order: [{ identifier: "main", enabled: true }] }, ], }, { name: "plain" }, ).preset; expect(resolveToolPlan(preset).present).toBe(false); }); }); describe("tool plan 缓存", () => { it("returns a stale plan until the cache is cleared", () => { const preset = presetWithPlaceholders(); expect(resolveToolPlan(preset).present).toBe(true); // 模拟用户把所有条目关掉后保存(id 与 importedAt 不变) const edited = { ...preset, promptOrder: preset.promptOrder.map((o) => ({ ...o, enabled: false })), }; expect(resolveToolPlan(edited).present).toBe(true); clearToolPlanCache(edited.id); expect(resolveToolPlan(edited).present).toBe(false); }); }); describe("decorateTools", () => { it("leaves tools alone when the preset has no tool protocol", () => { const preset = importSillyTavernPreset( { prompts: [{ identifier: "main", name: "main", role: "system", content: "写" }], prompt_order: [ { character_id: 100001, order: [{ identifier: "main", enabled: true }] }, ], }, { name: "plain" }, ).preset; const out = decorateTools({ mode: "adapt", tools: [submitTool], plan: resolveToolPlan(preset), }); expect(out.tools).toEqual([submitTool]); expect(out.aliases).toEqual([]); }); it("app mode never changes the tool table", () => { const out = decorateTools({ mode: "app", tools: [submitTool], plan: resolveToolPlan(presetWithBindings()), }); expect(out.tools).toEqual([submitTool]); }); it("adapt adds the declared tool and aliases it onto the host tool", () => { const out = decorateTools({ mode: "adapt", tools: [readTool, submitTool], plan: resolveToolPlan(presetWithBindings()), }); expect(out.tools.map((t) => t.function.name)).toEqual([ "read_blackboard", "submit", "game_content", ]); expect(out.aliases).toEqual([ { from: "game_content", to: "submit", payloadFields: ["content"] }, ]); }); it("adapt folds placeholder payload fields into the host schema", () => { const out = decorateTools({ mode: "adapt", tools: [readTool, submitTool], plan: resolveToolPlan(presetWithPlaceholders()), }); expect(out.tools).toHaveLength(2); const submit = out.tools.find((t) => t.function.name === "submit")!; const properties = (submit.function.parameters as { properties: Record; }).properties; expect(Object.keys(properties)).toContain("display_stream"); expect(submit.function.description).toContain("display_stream"); expect(out.aliases).toEqual([]); }); it("adapt creates a payload tool when there is no host tool", () => { const out = decorateTools({ mode: "adapt", tools: [], plan: resolveToolPlan(presetWithBindings()), }); expect(out.tools.map((t) => t.function.name)).toEqual(["game_content"]); }); it("preset mode sends only the preset tool", () => { const out = decorateTools({ mode: "preset", tools: [readTool, submitTool], plan: resolveToolPlan(presetWithBindings()), }); expect(out.tools.map((t) => t.function.name)).toEqual(["game_content"]); }); }); describe("normalizeToolCalls", () => { it("rewrites an aliased call onto the host tool and its primary field", () => { const decoration = decorateTools({ mode: "adapt", tools: [submitTool], plan: resolveToolPlan(presetWithBindings()), }); const calls = normalizeToolCalls( [ { id: "1", name: "game_content", arguments: JSON.stringify({ content: "第一章", extra: 1 }), }, ], decoration, decoration.tools, ); expect(calls[0]?.name).toBe("submit"); const args = JSON.parse(calls[0]!.arguments) as Record; // content 已经落到宿主工具的主字段上,不再重复 expect(args.content).toBe("第一章"); expect(args.extra).toBe(1); }); it("leaves unrelated calls untouched", () => { const decoration = decorateTools({ mode: "adapt", tools: [submitTool], plan: resolveToolPlan(presetWithBindings()), }); const call = { id: "2", name: "submit", arguments: '{"content":"x"}' }; expect(normalizeToolCalls([call], decoration, decoration.tools)).toEqual([ call, ]); }); it("renames an aliased call even without a payload", () => { const decoration = decorateTools({ mode: "adapt", tools: [submitTool], plan: resolveToolPlan(presetWithBindings()), }); const call = { id: "3", name: "game_content", arguments: "{}" }; expect(normalizeToolCalls([call], decoration, decoration.tools)).toEqual([ { ...call, name: "submit" }, ]); }); it("folds a payload field onto the host primary field", () => { const decoration = decorateTools({ mode: "adapt", tools: [submitTool], plan: resolveToolPlan(presetWithPlaceholders()), }); const calls = normalizeToolCalls( [ { id: "4", name: "submit", arguments: JSON.stringify({ display_stream: "正文" }), }, ], decoration, decoration.tools, ); const args = JSON.parse(calls[0]!.arguments) as Record; expect(args.content).toBe("正文"); expect(args).not.toHaveProperty("display_stream"); }); });