diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 2bf095cb..a64d9fd8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -975,6 +975,32 @@ public class AgentBindingService implements AgentBindingResolver { .orderByAsc(AgentWikiKbBinding::getCreateTime)); } + /** + * Effective KB ids the agent may see. Three states (mirror + * {@link #getBoundSkillIds}): + * + * + */ + @Override + public Set getBoundKbIds(Long agentId) { + List bindings = listKbBindings(agentId); + if (bindings.isEmpty()) { + return null; + } + return bindings.stream() + .filter(b -> Boolean.TRUE.equals(b.getEnabled())) + .map(AgentWikiKbBinding::getKbId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + /** * Replace the agent's KB access scope. An empty / null list clears the * scope, returning the agent to workspace-wide (unrestricted) access. diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 1b2ed83c..45499c16 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -77,6 +77,8 @@ public class WebChatController { private final vip.mate.audit.service.AuditEventService auditService; private final vip.mate.llm.routing.AgentBindingResolver agentBindingResolver; private final vip.mate.skill.repository.SkillMapper skillMapper; + private final vip.mate.wiki.repository.WikiPageMapper wikiPageMapper; + private final vip.mate.wiki.repository.WikiKnowledgeBaseMapper wikiKbMapper; /** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */ static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L; @@ -374,11 +376,168 @@ public class WebChatController { .toList()); } + /** + * 列出访客可见的 wiki 页面,供下游自建「`[[slug]]` 引用 picker」UI。 + *

+ * 鉴权链跟 {@link #listSkills} 一致:API Key 解析 channel + visitorToken + * HMAC 校验。{@code agentId} 可选,缺省回落到 channel 绑定的 agent; + * 必须属于该 channel 的 workspace(沿用 {@code /stream} 的反越权路径)。 + *

+ * 可见范围 = agent 绑定的 KB(无显式绑定时回落到 workspace 内全部 KB) + * 下的所有 page,排除 {@code pageType=synthesis}(LLM 中间产物)。 + * 上限 {@value WEBCHAT_WIKI_PICKER_MAX_PAGES}:超出时强制要求 {@code keyword}。 + *

+ * 出参仅暴露展示级元数据(slug / title / summary / pageType / kbId / + * kbName),不包含正文、embedding、sourceRawIds 等内部字段。 + */ + @Operation(summary = "列出访客可见 wiki 页面(供 [[slug]] picker UI)") + @GetMapping("/wiki/pages") + public R> listWikiPages( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam(required = false) Long agentId, + @RequestParam String visitorId, + @RequestParam(required = false) String keyword) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + // Resolve the target agent — same anti-escalation rule as /stream and + // /skills: an explicit agentId must belong to the channel's workspace. + Long resolvedAgentId = channel.getAgentId(); + if (agentId != null) { + var requested = agentService.getAgent(agentId); + if (requested == null) { + return R.fail(404, "Requested agent not found"); + } + if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null + && !channel.getWorkspaceId().equals(requested.getWorkspaceId())) { + return R.fail(403, "Requested agent does not belong to this channel's workspace"); + } + resolvedAgentId = agentId; + } + if (resolvedAgentId == null) { + return R.ok(List.of()); + } + + // Resolve the KB scope: null = workspace-wide (every KB in the agent's + // workspace); non-empty set = explicit allowlist. Set.of() (rows exist + // but none enabled) means "agent is explicitly scoped to zero KBs" — + // surface as empty so the picker shows nothing rather than falling + // through to workspace-wide. + java.util.Set boundKbIds = agentBindingResolver.getBoundKbIds(resolvedAgentId); + Long workspaceId = channel.getWorkspaceId(); + java.util.Set effectiveKbIds; + if (boundKbIds != null) { + if (boundKbIds.isEmpty()) { + return R.ok(List.of()); + } + effectiveKbIds = boundKbIds; + } else { + // No explicit binding → fall back to every KB in the channel's + // workspace. Matches the wiki-tool behavior (an unscoped agent + // sees workspace-wide KBs). + effectiveKbIds = wikiKbMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(vip.mate.wiki.model.WikiKnowledgeBaseEntity::getWorkspaceId, + workspaceId == null ? 1L : workspaceId)) + .stream() + .map(vip.mate.wiki.model.WikiKnowledgeBaseEntity::getId) + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.toSet()); + if (effectiveKbIds.isEmpty()) { + return R.ok(List.of()); + } + } + + // Build the page query: KB scope + exclude hidden pageTypes + optional + // keyword filter on slug/title. Use a single LIKE with OR so a visitor + // typing "auth" matches either "auth-design" (slug) or "Auth Design" (title). + String trimmedKeyword = keyword == null ? null : keyword.trim(); + boolean hasKeyword = trimmedKeyword != null && !trimmedKeyword.isEmpty(); + + // Cap check: if no keyword and total candidate count exceeds the cap, + // refuse — the caller must narrow with a keyword. Counting before + // selecting avoids materializing a huge list into memory. + // NOTE: the count wrapper is built WITHOUT ORDER BY — H2 in MySQL mode + // rejects "ORDER BY slug" on a COUNT(*) query (column must appear in + // GROUP BY). The select wrapper below adds ORDER BY slug. + if (!hasKeyword) { + long total = wikiPageMapper.selectCount(buildWikiPageFilterWrapper(effectiveKbIds, null)); + if (total > WEBCHAT_WIKI_PICKER_MAX_PAGES) { + return R.fail(422, "Wiki page count (" + total + + ") exceeds picker cap (" + WEBCHAT_WIKI_PICKER_MAX_PAGES + + "); please provide a 'keyword' query parameter to narrow."); + } + } + + List pages = wikiPageMapper.selectList( + buildWikiPageFilterWrapper(effectiveKbIds, hasKeyword ? trimmedKeyword : null) + .orderByAsc(vip.mate.wiki.model.WikiPageEntity::getSlug)); + if (pages.isEmpty()) { + return R.ok(List.of()); + } + + // Hydrate KB names so the picker UI can show ": " and + // the LLM can disambiguate when two KBs share a slug. + java.util.Map kbNames = wikiKbMapper.selectBatchIds(effectiveKbIds).stream() + .collect(java.util.stream.Collectors.toMap( + vip.mate.wiki.model.WikiKnowledgeBaseEntity::getId, + kb -> kb.getName() != null ? kb.getName() : "", + (a, b) -> a)); + + return R.ok(pages.stream() + .map(p -> WebChatWikiPageView.from(p, kbNames.get(p.getKbId()))) + .toList()); + } + + /** + * Build the WHERE-clause portion of the wiki-page picker query: KB scope + + * pageType-not-in-hidden + optional keyword LIKE on slug / title. + * Returned without ORDER BY so the caller can layer sorting (select) or + * nothing (count) on top — H2 in MySQL mode rejects ORDER BY on COUNT(*). + */ + private com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper + buildWikiPageFilterWrapper(java.util.Set kbIds, String keyword) { + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper w = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .in(vip.mate.wiki.model.WikiPageEntity::getKbId, kbIds) + .notIn(vip.mate.wiki.model.WikiPageEntity::getPageType, WEBCHAT_WIKI_HIDDEN_PAGE_TYPES); + if (keyword != null && !keyword.isEmpty()) { + String like = "%" + keyword + "%"; + w.and(qq -> qq.like(vip.mate.wiki.model.WikiPageEntity::getSlug, like) + .or().like(vip.mate.wiki.model.WikiPageEntity::getTitle, like)); + } + return w; + } + /** Cap on how many empty (message_count = 0) threads one visitor may hold on a * channel at once. Guards against pathologic clients churning placeholder * sessions without ever sending a message. */ private static final int MAX_EMPTY_SESSIONS_PER_VISITOR = 5; + /** + * Upper bound on the page count the wiki-page picker will return without a + * keyword filter. Beyond this the caller MUST supply {@code keyword} — + * returning 500 pages to a visitor picker is both bandwidth-wasteful and + * unusable as a UI. Mirrors the slash-skill picker's "small list, search + * when too big" stance. + */ + private static final int WEBCHAT_WIKI_PICKER_MAX_PAGES = 100; + + /** + * Page types hidden from the visitor picker. {@code synthesis} pages are + * LLM-generated intermediate artifacts (compiled on demand by + * {@code wiki_compile_page}); they aren't curated source material and + * surfacing them to a downstream visitor is noise. Entity / concept / + * source pages are human-readable references the visitor can meaningfully + * point the LLM at. + */ + private static final java.util.Set WEBCHAT_WIKI_HIDDEN_PAGE_TYPES = java.util.Set.of("synthesis"); + /** * 显式创建一条访客会话线程(空会话)。 *

@@ -1478,6 +1637,38 @@ public class WebChatController { } } + /** + * Display-level projection of a wiki page for the visitor-facing + * {@code [[slug]]} picker. Carries the slug the LLM consumes (via + * {@code wiki_read_page(slug=…)}), the human-readable title/summary for + * picker UI, and the KB id + name for disambiguation when an agent is + * scoped to multiple KBs that may share a slug. Deliberately omits + * content / embedding / sourceRawIds / outgoingLinks — those stay + * admin-console-only. + */ + @lombok.Data + public static class WebChatWikiPageView { + private Long kbId; + private String kbName; + /** Immutable slug — what the picker splices into the {@code [[slug]]} token; the LLM consumes it as {@code wiki_read_page(slug=…)}. */ + private String slug; + private String title; + private String summary; + /** {@code entity} / {@code concept} / {@code source} / ... — never {@code synthesis} (filtered out upstream). Useful for picker grouping/icons. */ + private String pageType; + + static WebChatWikiPageView from(vip.mate.wiki.model.WikiPageEntity p, String kbName) { + WebChatWikiPageView v = new WebChatWikiPageView(); + v.kbId = p.getKbId(); + v.kbName = kbName; + v.slug = p.getSlug(); + v.title = p.getTitle(); + v.summary = p.getSummary(); + v.pageType = p.getPageType(); + return v; + } + } + /** Body for {@code POST /sessions} — explicitly create an empty thread. */ @lombok.Data public static class WebChatCreateSessionRequest { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java index 7c778eec..a1b85407 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java @@ -4,8 +4,9 @@ import java.util.List; import java.util.Set; /** - * Read access to an agent's skill / provider bindings, as needed by - * {@link ProviderRouter} for capability-aware routing. + * Read access to an agent's skill / provider / wiki-kb bindings, as needed by + * {@link ProviderRouter} for capability-aware routing and by webchat + * endpoints that need to enumerate an agent's visible catalog. * *

Declared in the {@code llm} layer so the routing code depends only on * this abstraction. The {@code agent} layer supplies the implementation, @@ -23,4 +24,14 @@ public interface AgentBindingResolver { * Provider ids the agent prefers, in priority order; empty when none. */ List getPreferredProviderIds(Long agentId); + + /** + * Wiki knowledge-base ids bound to the agent, or {@code null} when the + * agent has no explicit KB scope (meaning "workspace-wide — every KB + * in the agent's workspace is visible"). Mirrors the three-state + * contract of {@link #getBoundSkillIds}: {@code null} = inherit + * default, {@code Set.of()} = explicitly scoped to nothing, non-empty + * = the explicit allowlist. + */ + Set getBoundKbIds(Long agentId); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index dda6a0de..526e3285 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -178,6 +178,10 @@ public class WikiTool { Use sectionHeading to read only one section by its heading text. The result includes a "sourceFiles" field listing the source documents this page was derived from. When using content from this page in your answer, cite the page title and source files. + + Convention: a user message containing `[[]]` (e.g. `参考知识库页面 [[auth-design]]: ...`) + is a wiki-page reference inserted by the chat picker. Treat each `[[slug]]` as a request to + consult that page first — call this tool with the bare slug before answering. """) public String wiki_read_page( @ToolParam(description = "Agent ID") Long agentId, diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java new file mode 100644 index 00000000..b7927e4b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java @@ -0,0 +1,251 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatWikiPageView; +import vip.mate.common.result.R; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end coverage for {@code GET /api/v1/channels/webchat/wiki/pages} — + * the visitor-facing wiki page catalogue that downstream integrators use to + * build a {@code [[slug]]} picker UI. Mirrors {@link WebChatSkillListTest}: + * verifies the auth chain (API Key + visitorToken HMAC), the agent workspace + * anti-escalation guard, the bound-KB scope, the {@code synthesis} exclusion, + * and the >100-page cap that forces a keyword filter. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_wiki_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatWikiPageListTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_400_001L; + private static final long AGENT_ID = 9_400_011L; + private static final long OTHER_WORKSPACE_AGENT_ID = 9_400_012L; + private static final long KB_ID = 9_400_101L; + private static final long OTHER_KB_ID = 9_400_102L; + + @Autowired private WebChatController controller; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + // Wipe bindings + pages + KBs + channel + agents in dependency-safe order. + jdbc.update("DELETE FROM mate_agent_wiki_kb WHERE agent_id IN (?, ?, ?)", + AGENT_ID, OTHER_WORKSPACE_AGENT_ID, 9_400_099L); + jdbc.update("DELETE FROM mate_wiki_page WHERE kb_id IN (?, ?)", KB_ID, OTHER_KB_ID); + jdbc.update("DELETE FROM mate_wiki_knowledge_base WHERE id IN (?, ?)", KB_ID, OTHER_KB_ID); + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id IN (?, ?, ?)", + AGENT_ID, OTHER_WORKSPACE_AGENT_ID, 9_400_099L); + + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-wiki-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-wiki-other-ws-agent', 'react', '', 10, TRUE, 999, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + OTHER_WORKSPACE_AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + + // Two KBs in workspace 1 (the channel's workspace). Bind the agent to + // KB_ID only, so OTHER_KB_ID stays out of scope unless the agent's + // binding set is cleared. + jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " + + "page_count, raw_count, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'Main KB', 'main', 'active', 0, 0, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + KB_ID); + jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " + + "page_count, raw_count, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'Other KB', 'other', 'active', 0, 0, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + OTHER_KB_ID); + + // Bind AGENT_ID to KB_ID only. Single enabled row → effective scope = {KB_ID}. + jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + 9_400_201L, AGENT_ID, KB_ID); + + // Three pages in KB_ID: entity / concept / synthesis. The synthesis + // one must be filtered out by the picker; the other two surface sorted + // by slug (beta < zeta by slug asc... actually we use 'alpha' and + // 'beta' to make the sort obvious). + insertPage(9_400_301L, KB_ID, "alpha-page", "Alpha Page", "entity"); + insertPage(9_400_302L, KB_ID, "beta-page", "Beta Page", "concept"); + insertPage(9_400_303L, KB_ID, "hidden-synthesis", "Synthesis (hidden)", "synthesis"); + + // One page in OTHER_KB_ID — must NOT surface (agent bound to KB_ID only). + insertPage(9_400_304L, OTHER_KB_ID, "other-kb-page", "Other KB Page", "entity"); + } + + private void insertPage(long id, long kbId, String slug, String title, String pageType) { + jdbc.update("MERGE INTO mate_wiki_page (id, kb_id, slug, title, content, summary, " + + "page_type, version, last_updated_by, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, ?, '', ?, ?, 1, 'test', " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, kbId, slug, title, title + " summary", pageType); + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + @Test + @DisplayName("happy path: bound-KB pages surface sorted by slug; synthesis filtered out; other-KB excluded") + void listReturnsBoundKbPagesSortedExcludingSynthesis() { + R> r = controller.listWikiPages( + API_KEY, tokenFor("v1"), null, "v1", null); + + assertThat(r.getCode()).isEqualTo(200); + List data = r.getData(); + assertThat(data).hasSize(2); + assertThat(data.get(0).getSlug()).isEqualTo("alpha-page"); + assertThat(data.get(0).getTitle()).isEqualTo("Alpha Page"); + assertThat(data.get(0).getKbId()).isEqualTo(KB_ID); + assertThat(data.get(0).getKbName()).isEqualTo("Main KB"); + assertThat(data.get(0).getPageType()).isEqualTo("entity"); + assertThat(data.get(1).getSlug()).isEqualTo("beta-page"); + // synthesis must NOT surface + assertThat(data).noneMatch(p -> "synthesis".equals(p.getPageType())); + // other-KB page must NOT surface (out of scope) + assertThat(data).noneMatch(p -> "other-kb-page".equals(p.getSlug())); + } + + @Test + @DisplayName("keyword filters by slug OR title (case-insensitive LIKE)") + void keywordFilterMatchesSlugOrTitle() { + R> bySlug = controller.listWikiPages( + API_KEY, tokenFor("v2"), null, "v2", "alpha"); + assertThat(bySlug.getCode()).isEqualTo(200); + assertThat(bySlug.getData()).hasSize(1); + assertThat(bySlug.getData().get(0).getSlug()).isEqualTo("alpha-page"); + + R> byTitle = controller.listWikiPages( + API_KEY, tokenFor("v3"), null, "v3", "Beta Page"); + assertThat(byTitle.getCode()).isEqualTo(200); + assertThat(byTitle.getData()).hasSize(1); + assertThat(byTitle.getData().get(0).getSlug()).isEqualTo("beta-page"); + + R> noMatch = controller.listWikiPages( + API_KEY, tokenFor("v4"), null, "v4", "nomatch"); + assertThat(noMatch.getCode()).isEqualTo(200); + assertThat(noMatch.getData()).isEmpty(); + } + + @Test + @DisplayName("explicit agentId matching channel workspace works") + void explicitAgentIdSameWorkspace() { + R> r = controller.listWikiPages( + API_KEY, tokenFor("v5"), AGENT_ID, "v5", null); + + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData()).hasSize(2); + } + + @Test + @DisplayName("explicit agentId in a different workspace → 403 (anti-escalation)") + void explicitAgentIdDifferentWorkspace() { + R> r = controller.listWikiPages( + API_KEY, tokenFor("v6"), OTHER_WORKSPACE_AGENT_ID, "v6", null); + + assertThat(r.getCode()).isEqualTo(403); + assertThat(r.getData()).isNull(); + } + + @Test + @DisplayName("invalid API Key → 401") + void invalidApiKey() { + R> r = controller.listWikiPages( + "garbagekeyxyz12", tokenFor("v7"), null, "v7", null); + + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("missing/invalid visitor token → 401") + void invalidVisitorToken() { + R> r = controller.listWikiPages( + API_KEY, "not-a-valid-token", null, "v8", null); + + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("no KB bindings → fall back to workspace-wide KB set (legacy behavior)") + void noKbBindingsFallsBackToWorkspaceWide() { + // Use a fresh agent with no mate_agent_wiki_kb rows. The channel is + // repointed to it so the default-agent path resolves it. + long lonelyAgent = 9_400_099L; + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-wiki-lonely', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + lonelyAgent); + jdbc.update("UPDATE mate_channel SET agent_id = ? WHERE id = ?", lonelyAgent, CHANNEL_ID); + + R> r = controller.listWikiPages( + API_KEY, tokenFor("v9"), null, "v9", null); + + assertThat(r.getCode()).isEqualTo(200); + // Both workspace KBs visible: alpha/beta from KB_ID + other-kb-page + // from OTHER_KB_ID. Synthesis still filtered. + assertThat(r.getData()).hasSize(3); + assertThat(r.getData()).extracting(WebChatWikiPageView::getSlug) + .containsExactlyInAnyOrder("alpha-page", "beta-page", "other-kb-page"); + } + + @Test + @DisplayName(">100 pages without keyword → 422 (force narrow with keyword)") + void capWithoutKeywordReturns422() { + // Seed 101 synthetic pages (slug = cap-001 ... cap-101) into KB_ID. + // These are extra to the 3 set up in @BeforeEach; synthesis-type rows + // still get filtered, so use 'entity' to make sure they all count. + for (int i = 1; i <= 101; i++) { + insertPage(9_401_000L + i, KB_ID, + String.format("cap-%03d", i), + "Cap Page " + i, + "entity"); + } + + R> noKeyword = controller.listWikiPages( + API_KEY, tokenFor("v10"), null, "v10", null); + assertThat(noKeyword.getCode()).isEqualTo(422); + assertThat(noKeyword.getData()).isNull(); + + // With a keyword the cap is bypassed — caller gets the filtered subset + // (here: 3 of the 101 seeded rows match "cap-001" / "cap-010" / "cap-100"). + R> withKeyword = controller.listWikiPages( + API_KEY, tokenFor("v11"), null, "v11", "cap-001"); + assertThat(withKeyword.getCode()).isEqualTo(200); + assertThat(withKeyword.getData()).isNotEmpty(); + } +}