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();
+ }
+}