package vip.mate.wiki.tool; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.ai.support.ToolCallbacks; import org.springframework.ai.tool.ToolCallback; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.service.HybridRetriever; import vip.mate.wiki.service.WikiKnowledgeBaseService; import vip.mate.wiki.service.WikiPageService; import vip.mate.wiki.service.WikiRawMaterialService; import java.util.Arrays; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** * Spring AI binding round-trip for the wiki tools' KB-routing contract. * *

The unit tests in {@link WikiToolKbNameRoutingTest} cover the Java-level * routing logic, but the LLM never calls those methods directly — it serializes * a JSON tool call which Spring AI's {@link ToolCallbacks#from} layer deserializes * back into method arguments. Two coercion hops in that pipeline are easy to * break unnoticed: * *

    *
  1. {@code wiki_list_kbs} returns {@code "kbId": ""} as a JSON * string (workspace-wide Snowflake-precision rule). When the LLM hands * that exact string back as {@code kbId} on a follow-up call, the Java * method declares {@code Long kbId} — so the framework must coerce * string → Long without precision loss.
  2. *
  3. OpenAI-style chat models frequently populate "unused numeric * optionals" with {@code 0}. The routing layer treats {@code kbId > 0} * as the only "supplied" sentinel; any binding change that lets a real * 19-digit id collapse to 0 (e.g. silent float coercion) would also * break the round-trip even though the unit tests still pass.
  4. *
* * These two tests pin the contract end-to-end. */ class WikiToolSpringBindingTest { private static final Long AGENT = 7L; private static final long PRIMARY_KB = 100L; // Real-shape Snowflake id — 19 digits, beyond JS Number.MAX_SAFE_INTEGER. // Verifies the precision-safe round-trip the workspace rule mandates. private static final long SNOWFLAKE_KB = 2054907618529591298L; private final WikiPageService pageService = mock(WikiPageService.class); private final WikiKnowledgeBaseService kbService = mock(WikiKnowledgeBaseService.class); private final WikiRawMaterialService rawService = mock(WikiRawMaterialService.class); private final HybridRetriever hybridRetriever = mock(HybridRetriever.class); private final ObjectMapper objectMapper = new ObjectMapper(); private final WikiTool tool = new WikiTool(pageService, kbService, rawService, hybridRetriever, objectMapper); private ToolCallback callbackFor(String functionName) { return Arrays.stream(ToolCallbacks.from(tool)) .filter(cb -> functionName.equals(cb.getToolDefinition().name())) .findFirst() .orElseThrow(() -> new AssertionError("No ToolCallback for " + functionName)); } private static WikiKnowledgeBaseEntity kb(long id, String name, Long agentId) { WikiKnowledgeBaseEntity e = new WikiKnowledgeBaseEntity(); e.setId(id); e.setName(name); e.setAgentId(agentId); e.setPageCount(0); return e; } private static WikiPageEntity page(String slug, String title) { WikiPageEntity p = new WikiPageEntity(); p.setSlug(slug); p.setTitle(title); p.setPageType("user"); return p; } @Test @DisplayName("wiki_list_kbs emits kbId as a JSON STRING and round-trips back through Long kbId") void kbIdRoundTripsAsStringWithoutPrecisionLoss() { // Arrange: one agent-bound KB whose id is a real-shape 19-digit // Snowflake. wiki_list_kbs must surface this as a string so a JS // hop never truncates it; the follow-up tool call then has to // accept that same string and coerce it back to a Long without loss. WikiKnowledgeBaseEntity snowflakeKb = kb(SNOWFLAKE_KB, "Big Data KB", AGENT); when(kbService.listByAgentId(AGENT)).thenReturn(List.of(snowflakeKb)); when(kbService.resolvePrimaryKb(AGENT)).thenReturn(snowflakeKb); when(kbService.findVisibleById(AGENT, SNOWFLAKE_KB)).thenReturn(snowflakeKb); when(pageService.listSummaries(eq(SNOWFLAKE_KB))).thenReturn(List.of( page("only-page", "Only Page"))); // Step 1: wiki_list_kbs through the real Spring AI ToolCallback binding. ToolCallback listKbs = callbackFor("wiki_list_kbs"); String listJson = listKbs.call("{\"agentId\":" + AGENT + "}"); JSONObject listObj = JSONUtil.parseObj(listJson); JSONArray kbs = listObj.getJSONArray("kbs"); assertThat(kbs).hasSize(1); JSONObject row = kbs.getJSONObject(0); // kbId MUST be a JSON string. Reading it back via getStr should equal // the exact 19-digit id; reading via getLong should also work (Hutool // parses string-or-number). The two checks combined catch a regression // that emits the id as a JSON number (which the LLM/JS hop would round). String advertisedKbId = row.getStr("kbId"); assertThat(advertisedKbId) .as("wiki_list_kbs MUST publish kbId as a string") .isEqualTo(String.valueOf(SNOWFLAKE_KB)); assertThat(row.get("kbId")) .as("the raw JSON node must be a String, not a Number") .isInstanceOf(String.class); // Step 2: feed that exact string back to wiki_list_pages as kbId, // exactly as an LLM tool call would. The framework must coerce // String → Long with no precision loss, and the routing layer must // resolve via findVisibleById (NOT fall back to primary). ToolCallback listPages = callbackFor("wiki_list_pages"); String pagesJson = listPages.call("{\"agentId\":" + AGENT + ",\"kbId\":\"" + advertisedKbId + "\"}"); JSONObject pagesObj = JSONUtil.parseObj(pagesJson); assertThat(pagesObj.getStr("error")) .as("string-kbId round-trip must NOT raise a not-visible error") .isNull(); assertThat(pagesObj.getJSONArray("pages").getJSONObject(0).getStr("slug")) .isEqualTo("only-page"); } @Test @DisplayName("kbId=0 from an LLM tool call falls through to primary instead of failing closed") void kbIdZeroFromToolCallFallsThroughToPrimary() { // The openai-chatgpt family was observed populating every unused // numeric optional with 0 in tool-call JSON. The routing layer // must treat that as "absent" — otherwise every wiki_* call // surfaces a spurious "kbId=0 not visible" error. WikiKnowledgeBaseEntity primary = kb(PRIMARY_KB, "Primary", AGENT); when(kbService.resolvePrimaryKb(AGENT)).thenReturn(primary); when(pageService.listSummaries(eq(PRIMARY_KB))).thenReturn(List.of( page("primary-page", "Primary Page"))); ToolCallback listPages = callbackFor("wiki_list_pages"); // Note: kbId arrives as JSON number 0 — the exact shape the // production regression had. String json = listPages.call("{\"agentId\":" + AGENT + ",\"kbId\":0}"); JSONObject obj = JSONUtil.parseObj(json); assertThat(obj.getStr("error")).isNull(); assertThat(obj.getJSONArray("pages").getJSONObject(0).getStr("slug")) .isEqualTo("primary-page"); } }