Compare commits

..

1 Commits

Author SHA1 Message Date
agent_coder f720151c63 refactor(ai-chat): move patch_node/insert_node metadata into the shared tool-spec registry (#294)
The same tool metadata (zod schema + model-facing description) was hand-duplicated
between the standalone MCP server and the in-app AI-chat agent, so every tweak had to
land in two places and copies drifted (a materialized parity bug). The shared
transport-agnostic registry (packages/mcp/src/tool-specs.ts) already de-duplicates 14
tools; this migrates two more genuinely-identical ones — patch_node/patchNode and
insert_node/insertNode. The canonical description is a strict SUPERSET of both originals
(keeps MCP's "without resending the whole document" + table-structure/anchor guidance
AND the in-app "reversible via page history" / "exactly one of anchorNodeId or
anchorText" framing — no model-facing guidance dropped); the schema is identical (the
in-app side just gains MCP's .min(1) on ids, a safe tightening). Each transport keeps its
own execute/auth wrapper, and the in-app parseNodeArg node-arg normalization is unchanged.

The three table tools are intentionally NOT merged (a real param-name divergence:
table vs tableRef) — documented on both sides. Other per-transport divergences
(search/share/create_comment/transform/list_pages) are left separate with a short comment
explaining why (the issue asked to flag these as intentional). DocmostClientLike stays a
hand-mirror (the ESM/CJS boundary blocks a compile-time type import; a runtime drift-guard
already pins it). Also fixes a latent contract-spec bug: derive `required` from
`instanceof z.ZodOptional` (matches the emitted JSON schema) instead of `isOptional()`,
which wrongly reported z.any() fields as optional.

Partially addresses #294.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:55:11 +03:00
27 changed files with 581 additions and 1369 deletions
@@ -1222,8 +1222,8 @@
"Commented": "Commented",
"Resolved comment": "Resolved comment",
"Ran tool {{name}}": "Ran tool {{name}}",
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
"AI agent {{name}}": "AI agent {{name}}",
"AI-agent": "AI-agent",
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
"Endpoints": "Endpoints",
"where we fetch models": "where we fetch models",
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
@@ -724,8 +724,7 @@
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
"AI agent {{name}}": "AI-агент {{name}}",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
@@ -1,101 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AgentAvatarStack } from "./agent-avatar-stack";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
type Props = React.ComponentProps<typeof AgentAvatarStack>;
function renderStack(props: Props) {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const utils = render(
<Provider store={store}>
<MantineProvider>
<AgentAvatarStack {...props} />
</MantineProvider>
</Provider>,
);
return { store, ...utils };
}
describe("AgentAvatarStack", () => {
it("internal chat WITH role: emoji glyph in front + human launcher behind", () => {
const { container } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
// Emoji is used as the glyph (priority 2), NOT the sparkles fallback.
expect(screen.getByText("🔬")).toBeDefined();
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
// Label: bold role name + dimmed "· launcher".
expect(screen.getByText("Researcher")).toBeDefined();
expect(screen.getByText(/·/)).toBeDefined();
expect(screen.getByText("Alice")).toBeDefined();
});
it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => {
const { container } = renderStack({
agent: { name: "AI agent", avatarUrl: null },
launcher: { name: "Bob", avatarUrl: null },
aiChatId: "chat-2",
});
// No avatarUrl and no emoji => sparkles glyph (priority 3).
expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull();
expect(screen.getByText("AI agent")).toBeDefined();
expect(screen.getByText("Bob")).toBeDefined();
});
it("external MCP: agent avatar in front, NO launcher behind", () => {
const { container } = renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
});
// avatarUrl provided (priority 1) => not the sparkles fallback.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
expect(screen.getByText("MCP Bot")).toBeDefined();
// No human behind => no "·" separator is rendered.
expect(screen.queryByText(/·/)).toBeNull();
// No internal chat => the stack is not an interactive deep-link button.
expect(screen.queryByRole("button")).toBeNull();
});
it("click deep-links into the chat when aiChatId is present", () => {
const { store } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
const button = screen.getByRole("button");
fireEvent.click(button);
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch
});
it("click is a no-op / not interactive without a chat target", () => {
const onActivate = vi.fn();
renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
onActivate,
});
expect(screen.queryByRole("button")).toBeNull();
expect(onActivate).not.toHaveBeenCalled();
});
});
@@ -1,183 +0,0 @@
import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// The FRONT identity (the acting agent) and the BEHIND identity (the human who
// launched it). Both are computed server-side (#300) so the client never branches
// on the internal-vs-MCP provenance — it just renders whatever it is handed.
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
// Same violet token as the former AiAgentBadge (which used color="violet").
const AGENT_COLOR = "violet";
const GLYPH_SIZE = 38;
const LAUNCHER_SIZE = 22;
/**
* The front avatar. Image-source priority (#300):
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
* 2. agent.emoji -> the role emoji on a violet circle.
* 3. otherwise -> the IconSparkles glyph on a violet circle (fallback).
*/
function AgentGlyph({ agent }: { agent: AgentInfo }) {
if (agent.avatarUrl) {
return (
<CustomAvatar
size={GLYPH_SIZE}
avatarUrl={agent.avatarUrl}
name={agent.name}
/>
);
}
if (agent.emoji) {
return (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
{agent.emoji}
</span>
</Avatar>
);
}
return (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
</Avatar>
);
}
export interface AgentAvatarStackProps {
agent: AgentInfo;
// null/absent => external MCP (front agent avatar only, no human behind).
launcher?: LauncherInfo | null;
// Deep-links into the internal AI chat when present (null for external MCP).
aiChatId?: string | null;
// Fired after the stack deep-links into its chat, so the caller can react
// (e.g. the page-history row closes the history modal). Keeps this ui/ primitive
// free of cross-feature coupling (inherited from the old AiAgentBadge, #143).
onActivate?: () => void;
}
/**
* The "agent avatar stack" (#300): the AGENT glyph in front, and — for an
* internal AI chat — the HUMAN who launched it as a smaller avatar offset behind.
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
* whole stack is a deep-link into that chat (the click the old badge owned moved
* here); the click is contained (stopPropagation) so it does not also trigger an
* enclosing row handler.
*/
export function AgentAvatarStack({
agent,
launcher,
aiChatId,
onActivate,
}: AgentAvatarStackProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const clickable = !!aiChatId;
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching chats must start with a clean composer — clear any unsent draft
// so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
// Internal chat => "role on behalf of person"; external MCP => just the agent.
const tooltip = launcher
? t("AI agent «{{role}}» on behalf of {{person}}", {
role: agent.name,
person: launcher.name,
})
: t("AI agent {{name}}", { name: agent.name });
const stack = (
<Box
pos="relative"
style={{
width: GLYPH_SIZE,
height: GLYPH_SIZE,
flexShrink: 0,
cursor: clickable ? "pointer" : undefined,
}}
{...(clickable
? {
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{launcher && (
<Box pos="absolute" bottom={0} right={0} style={{ zIndex: 0 }}>
<CustomAvatar
size={LAUNCHER_SIZE}
avatarUrl={launcher.avatarUrl}
name={launcher.name}
style={{ border: "2px solid var(--mantine-color-body)" }}
/>
</Box>
)}
<Box pos="relative" style={{ zIndex: 1 }}>
<AgentGlyph agent={agent} />
</Box>
</Box>
);
return (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Tooltip label={tooltip} withArrow>
{stack}
</Tooltip>
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{agent.name}
</Text>
{launcher && (
<>
<Text size="xs" c="dimmed" fw={400} aria-hidden>
·
</Text>
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
{launcher.name}
</Text>
</>
)}
</Group>
</Group>
);
}
export default AgentAvatarStack;
@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AiAgentBadge } from "./ai-agent-badge";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
return render(
<MantineProvider>
<AiAgentBadge {...props} />
</MantineProvider>,
);
}
// Render a clickable badge inside an explicit jotai store, with a leftover draft
// and an onActivate + parent-click spy, so the deep-link side effects are
// assertable. Returns the store and spies.
function setupClickable() {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const onActivate = vi.fn();
const onParentClick = vi.fn();
render(
<Provider store={store}>
<MantineProvider>
<div onClick={onParentClick}>
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
</div>
</MantineProvider>
</Provider>,
);
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
}
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
}
describe("AiAgentBadge", () => {
it("renders the AI-agent label", () => {
renderBadge({ authorName: "Bot" });
expect(screen.getByText("AI-agent")).toBeDefined();
});
it("is clickable (accessible button) when aiChatId is present", () => {
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
const badge = screen.getByRole("button");
expect(badge).toBeDefined();
expect(badge.textContent).toContain("AI-agent");
});
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
const { store, onActivate, onParentClick, badge } = setupClickable();
fireEvent.click(badge);
expectDeepLinked(store, onActivate);
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
});
it.each(["Enter", " "])(
"keyboard %j activates the deep-link (same side effects as click)",
(key) => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key });
expectDeepLinked(store, onActivate);
},
);
it("an unrelated key does NOT activate the badge", () => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key: "Tab" });
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
expect(onActivate).not.toHaveBeenCalled();
});
it.each([{ aiChatId: null }, {}])(
"is a plain non-clickable label without a chat target (%o)",
(props) => {
renderBadge({ authorName: "Bot", ...props });
expect(screen.getByText("AI-agent")).toBeDefined();
// No interactive role is exposed when there is no chat to deep-link into.
expect(screen.queryByRole("button")).toBeNull();
},
);
});
@@ -0,0 +1,99 @@
import { Badge, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
interface AiAgentBadgeProps {
authorName?: string;
aiChatId?: string | null;
// Fired after the badge deep-links into its chat. The caller handles its own
// context (e.g. the page-history row closes the history modal) so this generic
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
onActivate?: () => void;
}
/**
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
* page-history list and the comments sidebar.
*
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
* badge deep-links into that chat: it sets the active-chat atom and opens the
* floating AI-chat window, then invokes `onActivate` so the caller can react
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
* external MCP write with no internal ai_chats row), the badge is a plain
* non-clickable label. The click is contained (stopPropagation) so it does not
* also trigger an enclosing row's click handler.
*/
export function AiAgentBadge({
authorName,
aiChatId,
onActivate,
}: AiAgentBadgeProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
name: authorName ?? "",
});
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching to another chat must start with a clean composer — clear any
// unsent draft so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
const badge = (
<Badge
size="sm"
variant="light"
color="violet"
radius="sm"
leftSection={<IconSparkles size={12} stroke={2} />}
style={aiChatId ? { cursor: "pointer" } : undefined}
{...(aiChatId
? {
// Keep the default Badge root element (not a <button>) to avoid an
// invalid <button>-in-<button> nesting inside a row's
// UnstyledButton; expose it as an accessible button via
// role/keyboard.
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{t("AI-agent")}
</Badge>
);
return (
<Tooltip label={tooltip} withArrow>
{badge}
</Tooltip>
);
}
export default AiAgentBadge;
@@ -40,30 +40,20 @@ function renderItem(comment: IComment) {
);
}
describe("CommentListItem — agent avatar stack", () => {
it('renders the agent avatar stack when createdSource === "agent"', () => {
// External-MCP shape: agent is the account itself, no launcher behind.
renderItem(
baseComment({
createdSource: "agent",
aiChatId: null,
agent: { name: "Service Bot", avatarUrl: null },
launcher: null,
}),
);
// The stack renders the agent name label (the creator name is also shown in
// the row header, so it appears more than once).
expect(screen.getAllByText("Service Bot").length).toBeGreaterThan(0);
});
it('does NOT render the stack for a normal user comment (createdSource "user")', () => {
const { container } = renderItem(baseComment({ createdSource: "user" }));
// No agent glyph (sparkles) is present for a plain human comment.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
describe("CommentListItem — AI badge", () => {
it('renders the AI-agent badge when createdSource === "agent"', () => {
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
expect(screen.getByText("AI-agent")).toBeDefined();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
// only guards the insertion gate (agent → stack, user → no stack).
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
renderItem(baseComment({ createdSource: "user" }));
expect(screen.queryByText("AI-agent")).toBeNull();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
// the insertion gate (agent → badge, user → no badge) above (#143 review).
});
@@ -1,5 +1,5 @@
import { Group, Text, Box } from "@mantine/core";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
@@ -132,10 +132,9 @@ function CommentListItem({
{comment.creator.name}
</Text>
{comment.createdSource === "agent" && comment.agent && (
<AgentAvatarStack
agent={comment.agent}
launcher={comment.launcher}
{comment.createdSource === "agent" && (
<AiAgentBadge
authorName={comment.creator?.name}
aiChatId={comment.aiChatId}
/>
)}
@@ -1,9 +1,5 @@
import { IUser } from "@/features/user/types/user.types";
import { QueryParams } from "@/lib/types.ts";
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
export interface IComment {
id: string;
@@ -28,11 +24,6 @@ export interface IComment {
createdSource?: string;
aiChatId?: string | null;
resolvedSource?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// createdSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
yjsSelection?: {
anchor: any;
head: any;
@@ -1,6 +1,6 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
@@ -99,13 +99,12 @@ const HistoryItem = memo(function HistoryItem({
</>
)}
{isAgentEdit && historyItem.agent && (
<AgentAvatarStack
agent={historyItem.agent}
launcher={historyItem.launcher}
{isAgentEdit && (
<AiAgentBadge
authorName={historyItem.lastUpdatedBy?.name}
aiChatId={historyItem.lastUpdatedAiChatId}
// The history row owns the modal: close it when the stack deep-links
// into the chat (the stack no longer reaches into page-history).
// The history row owns the modal: close it when the badge deep-links
// into the chat (the badge no longer reaches into page-history).
onActivate={() => setHistoryModalOpen(false)}
/>
)}
@@ -1,8 +1,3 @@
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
interface IPageHistoryUser {
id: string;
name: string;
@@ -29,9 +24,4 @@ export interface IPageHistory {
// (when present) deep-links to the chat that produced the edit.
lastUpdatedSource?: string;
lastUpdatedAiChatId?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// lastUpdatedSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
}
@@ -173,6 +173,11 @@ export class AiChatToolsService {
});
return {
// INTENTIONAL per-transport divergence (not in the shared registry): this
// in-app search runs a semantic + keyword hybrid (RRF) with in-process
// access control and a tuned schema (limit 1-20); the standalone MCP
// `search` is a plain REST full-text search (limit up to 100). Different
// behaviour AND schema, so kept per-layer.
searchPages: tool({
description:
'Search the wiki for pages relevant to a query. Combines exact ' +
@@ -432,6 +437,10 @@ export class AiChatToolsService {
},
}),
// INTENTIONAL per-transport divergence (not shared): the description is
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
// keeps its own wording. Kept per-layer.
createComment: tool({
description:
'Add an INLINE comment to a page, or reply to an existing top-level ' +
@@ -519,6 +528,10 @@ export class AiChatToolsService {
async () => await client.getSpaces(),
),
// INTENTIONAL per-transport divergence (not shared): keeps the `tree:true`
// hierarchy mode but is worded for the in-app agent; the standalone MCP
// `list_pages` carries its own wording. Kept per-layer so each side tunes
// its own guidance.
listPages: tool({
description:
'List the most recent pages, optionally scoped to a single space. ' +
@@ -692,85 +705,25 @@ export class AiChatToolsService {
async ({ pageId }) => await client.stashPage(pageId),
),
patchNode: tool({
description:
'Replace a single content block (by id) with a new ProseMirror ' +
'node; the replacement keeps the same nodeId. Example node: a ' +
'paragraph {"type":"paragraph","content":[{"type":"text","text":"Hello"}]} ' +
'or a heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
'may be a JSON object or a JSON string (both accepted). Reversible: ' +
'the previous version is kept in page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
nodeId: z
.string()
.describe('The block id to replace (from getOutline/getPageJson).'),
node: z
.any()
.describe(
'The replacement ProseMirror node, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.',
),
}),
execute: async ({ pageId, nodeId, node }) => {
// Parity with the standalone MCP server (index.ts patch_node): the
// model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it.
// Schema + description from the shared registry (identical across both
// transports). The execute body keeps its OWN parseNodeArg normalization:
// the model sometimes serializes the node as a JSON string, and we parse it
// before the client's typeof-object guard rejects it (parity with the
// standalone MCP server, index.ts patch_node).
patchNode: sharedTool(
sharedToolSpecs.patchNode,
async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
return await client.patchNode(pageId, nodeId, parsedNode);
},
}),
),
insertNode: tool({
description:
'Insert a ProseMirror node relative to an anchor, or append it at ' +
'the top level. For before/after you MUST provide EXACTLY ONE of ' +
'anchorNodeId or anchorText. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
'may be a JSON object or a JSON string (both accepted). Reversible ' +
'via page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
node: z
.any()
.describe(
'The ProseMirror node to insert, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.',
),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z
.string()
.optional()
.describe('Anchor block id (for before/after).'),
anchorText: z
.string()
.optional()
.describe(
'Anchor text fragment (for before/after), matched against the ' +
"block's literal rendered plain text (no markdown). " +
'Markdown/emoji are tolerated as a fallback; prefer plain text ' +
'or anchorNodeId.',
),
}),
execute: async ({
pageId,
node,
position,
anchorNodeId,
anchorText,
}) => {
// Parity with the standalone MCP server (index.ts insert_node): the
// model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it.
// Shared registry schema + description; execute retains parseNodeArg on the
// incoming node (parity with the standalone MCP server, index.ts
// insert_node).
insertNode: sharedTool(
sharedToolSpecs.insertNode,
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
return await client.insertNode(pageId, parsedNode, {
position,
@@ -778,7 +731,7 @@ export class AiChatToolsService {
anchorText,
});
},
}),
),
deleteNode: sharedTool(
sharedToolSpecs.deleteNode,
@@ -821,6 +774,10 @@ export class AiChatToolsService {
},
}),
// NOT in the shared registry: this layer names the table argument
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
// Sharing one buildShape would rename a model-facing parameter on one
// transport, so the table row/cell tools stay per-layer by design.
tableInsertRow: tool({
description:
'Insert a row of plain-text cells into a table. Reversible via ' +
@@ -841,6 +798,8 @@ export class AiChatToolsService {
await client.tableInsertRow(pageId, tableRef, cells, index),
}),
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
// divergence as tableInsertRow.
tableDeleteRow: tool({
description:
'Delete a table row at a 0-based index. Reversible via page history.',
@@ -855,6 +814,8 @@ export class AiChatToolsService {
await client.tableDeleteRow(pageId, tableRef, index),
}),
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
// divergence as tableInsertRow.
tableUpdateCell: tool({
description:
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
@@ -884,6 +845,10 @@ export class AiChatToolsService {
await client.importPageMarkdown(pageId, markdown),
),
// INTENTIONAL per-transport divergence (not shared): adds a security
// confirmation framing ("Only share when the user explicitly asked, since
// this exposes the page to anyone with the link") for the in-app agent; the
// standalone MCP `share_page` keeps the plain public-URL wording.
sharePage: tool({
description:
'Make a page PUBLICLY accessible and return its public URL. ' +
@@ -910,6 +875,10 @@ export class AiChatToolsService {
async ({ historyId }) => await client.restorePageVersion(historyId),
),
// INTENTIONAL per-transport divergence (not shared): deliberately omits the
// `deleteComments` schema field (comment-deletion guardrail) and carries a
// much shorter description; the standalone MCP `docmost_transform` exposes
// the full helper catalogue. Different schema, so kept per-layer.
transformPage: tool({
description:
'Run a sandboxed JS transform of the form `(doc, ctx) => doc` over a ' +
@@ -113,9 +113,15 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
const expectedKeys = Object.keys(shape).sort();
expect(actualKeys).toEqual(expectedKeys);
// A non-.optional() field must surface as required in the advertised schema.
// A field that was NOT wrapped in `.optional()` must surface as required in
// the advertised schema. We test for the ZodOptional wrapper rather than
// `isOptional()`: `z.any()`/`z.unknown()` accept `undefined` and so report
// `isOptional() === true`, yet z.toJSONSchema still lists them under
// `required` (they carry no `.optional()`). Matching on the wrapper is what
// the emitted JSON schema actually does, so it stays correct for the
// registry's `node: z.any()` fields (patchNode/insertNode).
const expectedRequired = Object.entries(shape)
.filter(([, field]) => !(field as z.ZodTypeAny).isOptional?.())
.filter(([, field]) => !(field instanceof z.ZodOptional))
.map(([k]) => k)
.sort();
expect((json.required ?? []).slice().sort()).toEqual(expectedRequired);
@@ -1,237 +0,0 @@
import { CommentService } from './comment.service';
/**
* Caller-contract coverage for the three live comment broadcasts (#300/#304):
* - commentCreated (create @153)
* - commentUpdated (update @214) ← the fragile path this suite spotlights
* - commentResolved (resolveComment @283)
*
* All three must emit a payload carrying the {agent,launcher} avatar stack for an
* AGENT comment, and NEITHER field for a non-agent comment. The enrichment lives
* in CommentRepo.findById(..., {includeCreator:true}); the service contract these
* tests pin is that every broadcast reads its payload from that enriched
* single-row load rather than from an un-enriched object.
*
* NON-VACUITY for the update path: the service is handed an UN-enriched input
* comment (no agent/launcher), while findById returns the ENRICHED shape. The
* pre-#304 update() re-emitted the caller's object in place, so it would emit the
* un-enriched input and the `agent`/`launcher` assertions would FAIL. The fix
* re-fetches via findById, so the broadcast carries the stack regardless of how
* the caller pre-loaded the comment.
*/
describe('CommentService — broadcast carries the agent avatar stack', () => {
// An enriched agent comment as CommentRepo.findById(..., includeCreator:true)
// returns it: the {agent,launcher} pair is attached and agentRole is stripped.
const enrichedAgentComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'agent',
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
...over,
});
// A plain human comment: findById attaches neither agent nor launcher.
const plainHumanComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'user',
...over,
});
function makeService(findByIdReturn: unknown) {
const commentRepo: any = {
// In these flows findById is only the post-write enriched re-read
// (no parentCommentId is set, so no parent lookup path is taken).
findById: jest.fn(async () => findByIdReturn),
insertComment: jest.fn(async () => ({ id: 'comment-new' })),
updateComment: jest.fn(async () => undefined),
};
const pageRepo: any = {};
const wsService: any = { emitCommentEvent: jest.fn() };
const collaborationGateway: any = {
handleYjsEvent: jest.fn(async () => undefined),
};
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
const notificationQueue: any = { add: jest.fn(async () => undefined) };
const service = new CommentService(
commentRepo,
pageRepo,
wsService,
collaborationGateway,
generalQueue,
notificationQueue,
);
return { service, commentRepo, wsService };
}
// Pull the emitted event object (3rd arg of emitCommentEvent) for an operation.
const emittedEvent = (wsService: any, operation: string) =>
wsService.emitCommentEvent.mock.calls
.map((c: any[]) => c[2])
.find((e: any) => e.operation === operation);
const page = { id: 'page-1', spaceId: 'space-1' } as any;
const user = (id = 'user-1') => ({ id }) as any;
const emptyDoc = JSON.stringify({ type: 'doc', content: [] });
describe('commentCreated', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentUpdated — the fragile path (spotlight)', () => {
it('emits agent + launcher even when the caller pre-loaded an UN-enriched comment', async () => {
// findById (the re-fetch) returns the enriched shape...
const { service, wsService, commentRepo } = makeService(
enrichedAgentComment(),
);
// ...but the caller hands in an object with NO agent/launcher. The pre-#304
// update() re-emitted THIS object in place, so this test fails against it;
// the re-fetch fix makes the broadcast independent of the pre-load.
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
// deliberately no `agent` / `launcher`
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
// The broadcast must re-read the enriched row (persisted update, then load).
expect(commentRepo.updateComment).toHaveBeenCalled();
expect(commentRepo.findById).toHaveBeenCalledWith('comment-new', {
includeCreator: true,
includeResolvedBy: true,
});
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentResolved', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
});
@@ -207,27 +207,17 @@ export class CommentService {
false,
);
// Re-fetch the enriched comment before broadcasting, symmetric with
// create()/resolveComment(). updateComment() above has already persisted the
// new content/timestamps, so this single-row read reflects the edit AND
// carries the same {agent,launcher} avatar stack (via includeCreator) as the
// other two broadcasts. This deliberately does NOT reuse the caller's
// pre-loaded `comment`: relying on the controller happening to load it with
// includeCreator:true is exactly the fragile coupling that let the agent
// stack silently vanish on edit once already (#300/#304) — a future caller
// dropping that flag must not regress the broadcast.
const updatedComment = await this.commentRepo.findById(comment.id, {
includeCreator: true,
includeResolvedBy: true,
});
comment.content = commentContent;
comment.editedAt = editedAt;
comment.updatedAt = editedAt;
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment: updatedComment,
comment,
});
return updatedComment;
return comment;
}
async resolveComment(
@@ -1,129 +0,0 @@
import { resolveAgentProvenance } from './agent-provenance';
import { commentAgentRoleQuery } from './comment/comment.repo';
import { pageHistoryAgentRoleQuery } from './page/page-history.repo';
/**
* The server-authoritative "agent avatar stack" resolver (#300) normalizes the
* two provenance shapes into { agent (front), launcher (behind) } so the client
* never branches. These tests pin the exact resolved shape for the three agent
* cases plus the non-agent pass-through.
*/
describe('resolveAgentProvenance', () => {
const human = { name: 'Alice', avatarUrl: 'a.png' };
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: 'chat-1',
creator: human,
agentRole: { name: 'Researcher', emoji: '🔬' },
});
expect(result).toEqual({
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
});
});
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: 'chat-1',
creator: human,
agentRole: null,
});
expect(result).toEqual({
agent: { name: 'AI agent', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
});
// The fallback agent carries no emoji (only sparkles glyph on the client).
expect(result?.agent).not.toHaveProperty('emoji');
});
it('external MCP (aiChatId null): agent = the account itself, launcher = null', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: null,
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
expect(result).toEqual({
agent: { name: 'MCP Bot', avatarUrl: 'bot.png' },
launcher: null,
});
});
it('non-agent content: returns null so the caller omits both fields', () => {
expect(
resolveAgentProvenance({
isAgent: false,
aiChatId: null,
creator: human,
agentRole: null,
}),
).toBeNull();
});
});
/**
* The role-resolution subquery must NOT filter on enabled/deletedAt: historical
* agent content keeps its signature even after the role is disabled or
* soft-deleted (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). We
* record the query-builder calls and assert the join binds only id<->roleId and
* that `where` is never called with an enabled/deletedAt filter.
*/
describe('agent role subquery — no live/enabled filter', () => {
function makeRecorder() {
const calls: { method: string; args: unknown[] }[] = [];
const builder = new Proxy(
{},
{
get(_t, prop: string) {
return (...args: unknown[]) => {
calls.push({ method: prop, args });
return builder;
};
},
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eb = { selectFrom: (...args: unknown[]) => (calls.push({ method: 'selectFrom', args }), builder) } as any;
return { eb, calls };
}
function assertNoLiveFilter(
query: (eb: any) => unknown, // eslint-disable-line @typescript-eslint/no-explicit-any
chatIdColumn: string,
) {
const { eb, calls } = makeRecorder();
query(eb);
const innerJoin = calls.find((c) => c.method === 'innerJoin');
expect(innerJoin?.args).toEqual([
'aiAgentRoles',
'aiAgentRoles.id',
'aiChats.roleId',
]);
const whereRef = calls.find((c) => c.method === 'whereRef');
expect(whereRef?.args).toEqual(['aiChats.id', '=', chatIdColumn]);
// The security-narrowing filters used by findLiveEnabled must be ABSENT.
const filtered = calls
.flatMap((c) => c.args)
.filter((a) => a === 'enabled' || a === 'deletedAt');
expect(filtered).toEqual([]);
// No `where(...)` at all (only the join + whereRef).
expect(calls.some((c) => c.method === 'where')).toBe(false);
}
it('comment subquery joins by id only, keyed on comments.aiChatId', () => {
assertNoLiveFilter(commentAgentRoleQuery, 'comments.aiChatId');
});
it('page-history subquery joins by id only, keyed on lastUpdatedAiChatId', () => {
assertNoLiveFilter(
pageHistoryAgentRoleQuery,
'pageHistory.lastUpdatedAiChatId',
);
});
});
@@ -1,93 +0,0 @@
/**
* Server-authoritative "agent avatar stack" provenance (#300).
*
* Agent-authored content (comments / page-history snapshots) is displayed as a
* two-avatar stack: the AGENT in front, and the HUMAN who launched it behind.
* This module normalizes the two provenance shapes the client can encounter into
* the SAME pair of sub-objects so the client never has to branch:
*
* agent — FRONT (the acting agent identity)
* launcher — BEHIND (the human on whose behalf it acted; null when there is none)
*
* The discriminator is purely SERVER-SIDE data (createdSource / lastUpdatedSource
* plus aiChatId) that only the server can set — none of it is read from request
* input, so an external caller cannot spoof an `agent` badge.
*/
/** Front avatar identity. `avatarUrl`/`emoji` feed the glyph source priority. */
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
/** Behind avatar identity — the human who launched the agent (internal chat). */
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
/**
* Inputs to the resolver, drawn entirely from server-side columns:
* - `isAgent` — createdSource/lastUpdatedSource === 'agent'.
* - `aiChatId` — internal-AI-chat discriminator: non-null => internal chat (the
* provenance token was minted for the human, so `creator` is the human and the
* agent identity comes from the chat's role); null => external MCP (the login
* IS a dedicated agent account, so `creator` is the agent, no separate human).
* - `creator` — the row's human author (internal) OR agent account (MCP).
* - `agentRole`— the chat's bound role (name + optional emoji), resolved WITHOUT
* any enabled/deleted filter so historical content keeps its signature even
* after the role is disabled or soft-deleted; null when the chat has no role.
*/
export interface AgentProvenanceInput {
isAgent: boolean;
aiChatId: string | null | undefined;
creator: { name: string; avatarUrl?: string | null } | null | undefined;
agentRole: { name: string; emoji?: string | null } | null | undefined;
}
export interface AgentProvenance {
agent: AgentInfo;
launcher: LauncherInfo | null;
}
/** Fallback display name for an internal agent edit whose chat has no role. */
export const AGENT_FALLBACK_NAME = 'AI agent';
/**
* Resolve the front/behind identities from server-side provenance. Returns
* `null` for non-agent content so the caller can OMIT both fields (the client
* then keeps its plain single-human avatar).
*/
export function resolveAgentProvenance(
input: AgentProvenanceInput,
): AgentProvenance | null {
if (!input.isAgent) return null;
// External MCP: no internal chat row; the login itself is the agent account.
if (input.aiChatId == null) {
return {
agent: {
name: input.creator?.name ?? AGENT_FALLBACK_NAME,
avatarUrl: input.creator?.avatarUrl ?? null,
},
launcher: null,
};
}
// Internal AI chat: the agent identity is the chat's role (or the fallback
// when the chat has no role), and the launcher is the human chat owner.
const agent: AgentInfo = input.agentRole
? {
name: input.agentRole.name,
emoji: input.agentRole.emoji ?? null,
avatarUrl: null,
}
: { name: AGENT_FALLBACK_NAME, avatarUrl: null };
const launcher: LauncherInfo | null = input.creator
? { name: input.creator.name, avatarUrl: input.creator.avatarUrl ?? null }
: null;
return { agent, launcher };
}
@@ -1,124 +0,0 @@
import { CommentRepo } from './comment.repo';
/**
* Enrichment coverage for CommentRepo.findById (#300).
*
* The {agent,launcher} avatar stack must be attached on the SINGLE-ROW read
* path, not only on findPageComments — the live websocket broadcasts
* (commentCreated/commentUpdated/commentResolved) return a comment loaded via
* findById. These tests would FAIL against the previous un-enriched findById
* (which returned the raw row without calling attachCommentAgent and without
* selecting the agent-role subquery).
*
* The Kysely db is replaced by a chainable recorder so the query never touches a
* real database: it records the `.select(...)` args (to prove the agent-role
* subquery is selected on the includeCreator path) and returns a preset row from
* executeTakeFirst (to prove attachCommentAgent maps it into {agent,launcher}).
*/
describe('CommentRepo.findById — agent avatar stack enrichment', () => {
function makeRepo(row: unknown) {
const selectArgs: unknown[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builder: any = {
selectFrom: () => builder,
selectAll: () => builder,
select: (arg: unknown) => {
selectArgs.push(arg);
return builder;
},
// Kysely's $if(condition, cb) invokes cb(qb) only when the condition is
// truthy; mirror that so gating (includeCreator) is exercised faithfully.
$if: (cond: unknown, cb: (qb: unknown) => unknown) => {
if (cond) cb(builder);
return builder;
},
where: () => builder,
executeTakeFirst: async () => row,
};
const db = { selectFrom: () => builder };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const repo = new CommentRepo(db as any);
return { repo, selectArgs };
}
const enrichOpts = { includeCreator: true, includeResolvedBy: true };
it('internal agent chat WITH role: returns agent = role, launcher = creator, and strips agentRole', async () => {
const { repo, selectArgs } = makeRepo({
id: 'c-1',
createdSource: 'agent',
aiChatId: 'chat-1',
creator: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: { name: 'Researcher', emoji: '🔬' },
});
const result: any = await repo.findById('c-1', enrichOpts);
expect(result.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(result.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
// The internal join column must never leak to the client.
expect(result).not.toHaveProperty('agentRole');
// The enrichment SELECTs the agent-role subquery on the includeCreator path
// (mirrors the list-query proof; absent in the pre-fix findById).
expect(selectArgs).toContain(repo.withAgentRole);
});
it('external MCP agent (aiChatId null): agent = the account, launcher = null', async () => {
const { repo } = makeRepo({
id: 'c-2',
createdSource: 'agent',
aiChatId: null,
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
const result: any = await repo.findById('c-2', enrichOpts);
expect(result.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
expect(result.launcher).toBeNull();
expect(result).not.toHaveProperty('agentRole');
});
it('non-agent comment: neither agent nor launcher is attached', async () => {
const { repo } = makeRepo({
id: 'c-3',
createdSource: 'user',
aiChatId: null,
creator: { name: 'Bob', avatarUrl: null },
agentRole: null,
});
const result: any = await repo.findById('c-3', enrichOpts);
expect(result).not.toHaveProperty('agent');
expect(result).not.toHaveProperty('launcher');
// A plain human comment still strips the internal join column.
expect(result).not.toHaveProperty('agentRole');
});
it('missing row: returns undefined without crashing the enrichment', async () => {
const { repo } = makeRepo(undefined);
await expect(repo.findById('nope', enrichOpts)).resolves.toBeUndefined();
});
it('non-includeCreator callers keep the plain shape (no enrichment, no agent-role select)', async () => {
const { repo, selectArgs } = makeRepo({
id: 'c-4',
createdSource: 'agent',
aiChatId: 'chat-1',
});
// No opts => the enrichment (and its subquery select) must be skipped, so
// callers doing a bare lookup (parent-comment check, controller findOne)
// are unaffected by the additive fields.
const result: any = await repo.findById('c-4');
expect(result).not.toHaveProperty('agent');
expect(result).not.toHaveProperty('launcher');
expect(selectArgs).not.toContain(repo.withAgentRole);
});
});
@@ -12,24 +12,6 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { resolveAgentProvenance } from '../agent-provenance';
/**
* Role-resolution subquery for a comment's bound AI chat (#300). Joins
* comments.aiChatId -> ai_chats.role_id -> ai_agent_roles and selects the role's
* name + emoji. NO enabled/deletedAt filter: historical agent content must keep
* its signature even after the role is later disabled or soft-deleted — the same
* "resolve by id, ignore live/enabled" rule as AiAgentRoleRepo.findById (NOT
* findLiveEnabled). Exported so a unit test can assert the join binds only
* id<->roleId and never filters on enabled/deletedAt.
*/
export function commentAgentRoleQuery(eb: ExpressionBuilder<DB, 'comments'>) {
return eb
.selectFrom('aiChats')
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
.whereRef('aiChats.id', '=', 'comments.aiChatId');
}
@Injectable()
export class CommentRepo {
@@ -40,30 +22,13 @@ export class CommentRepo {
commentId: string,
opts?: { includeCreator: boolean; includeResolvedBy: boolean },
): Promise<Comment> {
const comment = await this.db
return await this.db
.selectFrom('comments')
.selectAll('comments')
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
.$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
// #300: enrich the single-row read with the agent-role subquery so the
// {agent,launcher} avatar stack is attached here too — the live websocket
// broadcasts (commentCreated/Updated/Resolved) return a comment loaded via
// findById, and must carry the SAME provenance as the list query
// findPageComments. Without this a freshly created / edited / resolved
// agent comment arrives un-enriched and the client's
// `createdSource === 'agent' && agent` gate drops the stack until a full
// refetch. Gated on includeCreator (mirroring findPageComments, which
// always selects the creator): the internal-chat launcher IS the creator,
// so the resolver needs it, and every broadcast caller passes
// includeCreator: true. Non-includeCreator callers keep the plain shape.
.$if(opts?.includeCreator, (qb) => qb.select(this.withAgentRole))
.where('id', '=', commentId)
.executeTakeFirst();
// Guard a missing row (don't destructure undefined in attachCommentAgent)
// and leave non-enriched callers' shape untouched.
if (!comment || !opts?.includeCreator) return comment;
return attachCommentAgent(comment) as Comment;
}
async findPageComments(pageId: string, pagination: PaginationOptions) {
@@ -72,18 +37,15 @@ export class CommentRepo {
.selectAll('comments')
.select((eb) => this.withCreator(eb))
.select((eb) => this.withResolvedBy(eb))
.select((eb) => this.withAgentRole(eb))
.where('pageId', '=', pageId);
const result = await executeWithCursorPagination(query, {
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return { ...result, items: result.items.map(attachCommentAgent) };
}
async updateComment(
@@ -120,12 +82,6 @@ export class CommentRepo {
).as('creator');
}
/** Select the comment's resolved chat role (name + emoji) as `agentRole`, or
* null when the comment has no internal chat / the chat has no role (#300). */
withAgentRole(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(commentAgentRoleQuery(eb)).as('agentRole');
}
withResolvedBy(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(
eb
@@ -160,30 +116,3 @@ export class CommentRepo {
return Number(result?.count) > 0;
}
}
/**
* Attach the normalized agent/launcher provenance (#300) to a comment row and
* strip the internal `agentRole` join column. Non-agent rows pass through
* unchanged (neither field added — the client keeps the plain human avatar). The
* human author (`creator`) is the launcher for an internal chat, or the agent
* itself for external MCP; the resolver encodes both cases.
*/
function attachCommentAgent<
R extends {
createdSource?: string | null;
aiChatId?: string | null;
creator?: { name: string; avatarUrl?: string | null } | null;
agentRole?: { name: string; emoji?: string | null } | null;
},
>(row: R) {
const { agentRole, ...rest } = row;
const provenance = resolveAgentProvenance({
isAgent: row.createdSource === 'agent',
aiChatId: row.aiChatId,
creator: row.creator,
agentRole,
});
return provenance
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
: rest;
}
@@ -1,107 +0,0 @@
import { PageHistoryRepo } from './page-history.repo';
/**
* Enrichment coverage for the page-history agent avatar stack (#300/#304).
*
* attachPageHistoryAgent maps a DIFFERENT column set than comments —
* `lastUpdatedSource` / `lastUpdatedAiChatId` / `lastUpdatedBy` instead of
* `createdSource` / `aiChatId` / `creator` — so it needs its own direct proof
* that the {agent,launcher} pair resolves for each provenance shape and that the
* internal `agentRole` join column is stripped.
*
* The mapping is exercised through findPageHistoryByPageId (the only page-history
* path that enriches). The Kysely db is a chainable recorder: query-builder
* methods return the builder and `.execute()` (called by
* executeWithCursorPagination) yields preset rows, so no real database is
* touched. The `.select((eb) => ...)` callbacks are recorded but never invoked,
* so the preset row stands in for what the DB would have returned.
*
* NON-VACUITY: against an identity mapping (raw row pass-through) the agent-case
* assertions fail — `agent`/`launcher` would be undefined and the internal
* `agentRole` column would leak.
*/
describe('PageHistoryRepo.findPageHistoryByPageId — agent avatar stack enrichment', () => {
function makeRepo(rows: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builder: any = {
selectFrom: () => builder,
select: () => builder,
where: () => builder,
orderBy: () => builder,
limit: () => builder,
execute: async () => rows,
};
const db = { selectFrom: () => builder };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new PageHistoryRepo(db as any);
}
// perPage high enough that a single preset row never triggers the extra-row
// "has next page" branch (which would call generateCursor).
const pagination = { limit: 50 } as any;
const firstItem = async (row: Record<string, unknown>) => {
const repo = makeRepo([row]);
const result = await repo.findPageHistoryByPageId('page-1', pagination);
return result.items[0] as any;
};
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human, agentRole stripped', async () => {
const item = await firstItem({
id: 'ph-1',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: 'chat-1',
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: { name: 'Editor', emoji: '✏️' },
});
expect(item.agent).toEqual({ name: 'Editor', emoji: '✏️', avatarUrl: null });
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
// The internal join column must never leak to the client.
expect(item).not.toHaveProperty('agentRole');
});
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', async () => {
const item = await firstItem({
id: 'ph-2',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: 'chat-1',
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: null,
});
expect(item.agent).toEqual({ name: 'AI agent', avatarUrl: null });
expect(item.agent).not.toHaveProperty('emoji');
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
expect(item).not.toHaveProperty('agentRole');
});
it('external MCP (lastUpdatedAiChatId null): agent = the account itself, launcher = null', async () => {
const item = await firstItem({
id: 'ph-3',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: null,
lastUpdatedBy: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
expect(item.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
expect(item.launcher).toBeNull();
expect(item).not.toHaveProperty('agentRole');
});
it('non-agent (lastUpdatedSource !== "agent"): neither agent nor launcher, agentRole stripped', async () => {
const item = await firstItem({
id: 'ph-4',
lastUpdatedSource: 'user',
lastUpdatedAiChatId: null,
lastUpdatedBy: { name: 'Bob', avatarUrl: null },
agentRole: null,
});
expect(item).not.toHaveProperty('agent');
expect(item).not.toHaveProperty('launcher');
// A plain human row still strips the internal join column.
expect(item).not.toHaveProperty('agentRole');
});
});
@@ -12,25 +12,6 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { resolveAgentProvenance } from '../agent-provenance';
/**
* Role-resolution subquery for a page-history row's bound AI chat (#300). Joins
* pageHistory.lastUpdatedAiChatId -> ai_chats.role_id -> ai_agent_roles and
* selects the role's name + emoji. NO enabled/deletedAt filter: historical agent
* content must keep its signature even after the role is disabled or soft-deleted
* (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). Exported so a
* unit test can assert the join never filters on enabled/deletedAt.
*/
export function pageHistoryAgentRoleQuery(
eb: ExpressionBuilder<DB, 'pageHistory'>,
) {
return eb
.selectFrom('aiChats')
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
.whereRef('aiChats.id', '=', 'pageHistory.lastUpdatedAiChatId');
}
@Injectable()
export class PageHistoryRepo {
@@ -113,18 +94,15 @@ export class PageHistoryRepo {
.select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.select((eb) => this.withAgentRole(eb))
.where('pageId', '=', pageId);
const result = await executeWithCursorPagination(query, {
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return { ...result, items: result.items.map(attachPageHistoryAgent) };
}
async findPageLastHistory(
@@ -160,12 +138,6 @@ export class PageHistoryRepo {
).as('lastUpdatedBy');
}
/** Select the row's resolved chat role (name + emoji) as `agentRole`, or null
* when there is no internal chat / the chat has no role (#300). */
withAgentRole(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonObjectFrom(pageHistoryAgentRoleQuery(eb)).as('agentRole');
}
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
@@ -179,30 +151,3 @@ export class PageHistoryRepo {
).as('contributors');
}
}
/**
* Attach the normalized agent/launcher provenance (#300) to a page-history row
* and strip the internal `agentRole` join column. The trigger is
* `lastUpdatedSource === 'agent'`, the internal-chat discriminator is
* `lastUpdatedAiChatId`, and the human is `lastUpdatedBy`. Non-agent rows pass
* through unchanged (neither field added).
*/
function attachPageHistoryAgent<
R extends {
lastUpdatedSource?: string | null;
lastUpdatedAiChatId?: string | null;
lastUpdatedBy?: { name: string; avatarUrl?: string | null } | null;
agentRole?: { name: string; emoji?: string | null } | null;
},
>(row: R) {
const { agentRole, ...rest } = row;
const provenance = resolveAgentProvenance({
isAgent: row.lastUpdatedSource === 'agent',
aiChatId: row.lastUpdatedAiChatId,
creator: row.lastUpdatedBy,
agentRole,
});
return provenance
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
: rest;
}
-1
View File
@@ -1 +0,0 @@
node_modules/node_modules
+34 -52
View File
@@ -76,6 +76,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(spaces);
});
// Tool: list_pages
// INTENTIONAL per-transport divergence (not in the shared registry): this
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
// the in-app copy keeps the same tree option but is worded for the in-app agent.
// Kept per-layer so each side can tune its own guidance.
server.registerTool("list_pages", {
description: "List most recent pages in a space ordered by updatedAt (descending). " +
"Returns a bounded list (default 50, max 100) — use search for lookups " +
@@ -143,6 +147,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: table_insert_row
// NOT in the shared registry: this transport names the table argument `table`,
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
// one buildShape would rename a public MCP parameter, so the table row/cell
// tools stay per-transport by design.
server.registerTool("table_insert_row", {
description: "Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
"a block id inside it. `cells` = text per column (padded to the table's " +
@@ -159,6 +167,8 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: table_delete_row
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool("table_delete_row", {
description: "Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
"a block id inside it). Refuses to delete the table's only row. An " +
@@ -174,6 +184,8 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: table_update_cell
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool("table_update_cell", {
description: "Set the plain-text content of cell [row,col] (0-based) in a table " +
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
@@ -317,62 +329,17 @@ export function createDocmostMcpServer(config) {
},
};
});
// Tool: patch_node
server.registerTool("patch_node", {
description: "Replaces a single block identified by its attrs.id WITHOUT resending the " +
"whole document. Get the block id from get_page_json, then pass a " +
"ProseMirror node to put in its place. Example node: a paragraph " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
"JSON object or a JSON string (both accepted). Cheaper and safer than " +
"update_page_json for one-block structural edits.",
inputSchema: {
pageId: z.string().min(1),
nodeId: z.string().min(1),
node: z
.any()
.describe("ProseMirror node to put in place of the node with this id, e.g. " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
"JSON object or JSON string both accepted."),
},
}, async ({ pageId, nodeId, node }) => {
// Tool: patch_node — schema + description from the shared registry (identical
// across both transports). The execute body keeps its own parseNodeArg
// normalization (the model sometimes serializes `node` as a JSON string).
registerShared(SHARED_TOOL_SPECS.patchNode, async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
return jsonContent(result);
});
// Tool: insert_node
server.registerTool("insert_node", {
description: "Insert a block before/after another block (by attrs.id or anchor text) " +
"or append at the end. Get anchor block ids from get_page_json. Avoids " +
"resending the whole document. Can also insert table structure: to add a " +
"tableRow, pass a tableRow node with position before/after and anchor " +
"INSIDE the target table — anchorNodeId of any block/cell in it, or " +
"anchorText matching the table; to add a tableCell/tableHeader, use " +
"anchorNodeId of a block inside the target row (anchorText only resolves " +
"top-level blocks, so it cannot target a row). `anchorText` is matched " +
"against the block's literal rendered plain text (no markdown); " +
"markdown/emoji are tolerated as a fallback; prefer plain text or " +
"anchorNodeId. Note: append is top-level " +
"only and rejects structural table nodes. Example node: a paragraph " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
"JSON object or a JSON string (both accepted).",
inputSchema: {
pageId: z.string().min(1),
node: z
.any()
.describe("ProseMirror node to insert, e.g. " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
"JSON object or JSON string both accepted."),
position: z.enum(["before", "after", "append"]),
anchorNodeId: z.string().optional(),
anchorText: z.string().optional(),
},
}, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
// Tool: insert_node — schema + description from the shared registry. As with
// patch_node, the execute body retains parseNodeArg on the incoming node.
registerShared(SHARED_TOOL_SPECS.insertNode, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
position,
@@ -453,6 +420,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: share_page
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
// security-confirmation framing ("only share when the user explicitly asked,
// since this exposes the page to anyone with the link") tuned for the in-app
// agent; this transport keeps the plain public-URL wording.
server.registerTool("share_page", {
description: "Make a page publicly accessible (idempotent) and return its public " +
"URL. The URL format is <app>/share/<key>/p/<slugId>.",
@@ -539,6 +510,9 @@ export function createDocmostMcpServer(config) {
return jsonContent(comments);
});
// Tool: create_comment
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); this transport keeps its own wording.
server.registerTool("create_comment", {
description: "Create a new comment on a page. The comment is ALWAYS inline and is " +
"anchored to (highlights) its `selection` text — there are no page-level " +
@@ -652,6 +626,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: search
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
// different schema (limit 1-20); this transport is a plain REST full-text search
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
server.registerTool("search", {
description: "Search for pages and content. Results are bounded by `limit` " +
"(default applied by the client, max 100).",
@@ -672,6 +650,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: docmost_transform
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
// deliberately omits the `deleteComments` schema field (comment-deletion
// guardrail) and carries a much shorter description; this transport exposes the
// full helper catalogue. Different schema, so kept per-layer.
server.registerTool("docmost_transform", {
description: "Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
"against its LIVE ProseMirror document, with a diff preview and page " +
+80
View File
@@ -80,6 +80,86 @@ export const SHARED_TOOL_SPECS = {
nodeId: z.string().min(1),
}),
},
// --- single-block structural write (patch / insert) ---
//
// CANONICAL description merges both layers: the MCP copy's "WITHOUT resending
// the whole document" + "cheaper/safer than a full-document replace" guidance
// AND the in-app copy's "keeps the same node id" + "Reversible via page
// history" framing — nothing either side conveyed is dropped. Sibling tools are
// named in transport-neutral prose ("the page-JSON view", "a full-document
// replace") to match the rest of the registry, since the two layers expose
// those siblings under different (snake_case vs camelCase) identifiers.
patchNode: {
mcpName: 'patch_node',
inAppKey: 'patchNode',
description: 'Replace a single content block identified by its attrs.id with a new ' +
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
'keeps the same node id. Get the block id from the page-JSON view, then ' +
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
'replacing the whole document for one-block structural edits. Reversible: ' +
'the previous version is kept in page history.',
buildShape: (z) => ({
pageId: z.string().min(1).describe('ID of the page containing the block'),
nodeId: z
.string()
.min(1)
.describe('attrs.id of the block to replace (from the page outline or ' +
'page-JSON view)'),
node: z
.any()
.describe('ProseMirror node to put in place of the node with this id, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.'),
}),
},
insertNode: {
mcpName: 'insert_node',
inAppKey: 'insertNode',
description: 'Insert a block before/after another block (by attrs.id or anchor text) ' +
'or append it at the end (top level). For before/after you MUST provide ' +
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
'page-JSON view. Avoids resending the whole document. Can also insert ' +
'table structure: to add a tableRow, pass a tableRow node with position ' +
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
'block/cell in it, or anchorText matching the table; to add a ' +
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' +
'(anchorText only resolves top-level blocks, so it cannot target a row). ' +
"`anchorText` is matched against the block's literal rendered plain text " +
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
'text or anchorNodeId. Note: append is top-level only and rejects ' +
'structural table nodes. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Reversible via page history.',
buildShape: (z) => ({
pageId: z.string().min(1),
node: z
.any()
.describe('ProseMirror node to insert, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.'),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z
.string()
.optional()
.describe('Anchor block id (for before/after).'),
anchorText: z
.string()
.optional()
.describe("Anchor text fragment (for before/after), matched against the " +
"block's literal rendered plain text (no markdown). Markdown/emoji " +
'are tolerated as a fallback; prefer plain text or anchorNodeId.'),
}),
},
// --- share management ---
unsharePage: {
mcpName: 'unshare_page',
+36 -62
View File
@@ -105,6 +105,10 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
});
// Tool: list_pages
// INTENTIONAL per-transport divergence (not in the shared registry): this
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
// the in-app copy keeps the same tree option but is worded for the in-app agent.
// Kept per-layer so each side can tune its own guidance.
server.registerTool(
"list_pages",
{
@@ -195,6 +199,10 @@ server.registerTool(
);
// Tool: table_insert_row
// NOT in the shared registry: this transport names the table argument `table`,
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
// one buildShape would rename a public MCP parameter, so the table row/cell
// tools stay per-transport by design.
server.registerTool(
"table_insert_row",
{
@@ -222,6 +230,8 @@ server.registerTool(
);
// Tool: table_delete_row
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool(
"table_delete_row",
{
@@ -243,6 +253,8 @@ server.registerTool(
);
// Tool: table_update_cell
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool(
"table_update_cell",
{
@@ -445,32 +457,11 @@ server.registerTool(
},
);
// Tool: patch_node
server.registerTool(
"patch_node",
{
description:
"Replaces a single block identified by its attrs.id WITHOUT resending the " +
"whole document. Get the block id from get_page_json, then pass a " +
"ProseMirror node to put in its place. Example node: a paragraph " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
"JSON object or a JSON string (both accepted). Cheaper and safer than " +
"update_page_json for one-block structural edits.",
inputSchema: {
pageId: z.string().min(1),
nodeId: z.string().min(1),
node: z
.any()
.describe(
"ProseMirror node to put in place of the node with this id, e.g. " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
"JSON object or JSON string both accepted.",
),
},
},
// Tool: patch_node — schema + description from the shared registry (identical
// across both transports). The execute body keeps its own parseNodeArg
// normalization (the model sometimes serializes `node` as a JSON string).
registerShared(
SHARED_TOOL_SPECS.patchNode,
async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
@@ -478,42 +469,10 @@ server.registerTool(
},
);
// Tool: insert_node
server.registerTool(
"insert_node",
{
description:
"Insert a block before/after another block (by attrs.id or anchor text) " +
"or append at the end. Get anchor block ids from get_page_json. Avoids " +
"resending the whole document. Can also insert table structure: to add a " +
"tableRow, pass a tableRow node with position before/after and anchor " +
"INSIDE the target table — anchorNodeId of any block/cell in it, or " +
"anchorText matching the table; to add a tableCell/tableHeader, use " +
"anchorNodeId of a block inside the target row (anchorText only resolves " +
"top-level blocks, so it cannot target a row). `anchorText` is matched " +
"against the block's literal rendered plain text (no markdown); " +
"markdown/emoji are tolerated as a fallback; prefer plain text or " +
"anchorNodeId. Note: append is top-level " +
"only and rejects structural table nodes. Example node: a paragraph " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
"JSON object or a JSON string (both accepted).",
inputSchema: {
pageId: z.string().min(1),
node: z
.any()
.describe(
"ProseMirror node to insert, e.g. " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
"JSON object or JSON string both accepted.",
),
position: z.enum(["before", "after", "append"]),
anchorNodeId: z.string().optional(),
anchorText: z.string().optional(),
},
},
// Tool: insert_node — schema + description from the shared registry. As with
// patch_node, the execute body retains parseNodeArg on the incoming node.
registerShared(
SHARED_TOOL_SPECS.insertNode,
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
@@ -619,6 +578,10 @@ server.registerTool(
);
// Tool: share_page
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
// security-confirmation framing ("only share when the user explicitly asked,
// since this exposes the page to anyone with the link") tuned for the in-app
// agent; this transport keeps the plain public-URL wording.
server.registerTool(
"share_page",
{
@@ -746,6 +709,9 @@ server.registerTool(
);
// Tool: create_comment
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); this transport keeps its own wording.
server.registerTool(
"create_comment",
{
@@ -911,6 +877,10 @@ server.registerTool(
);
// Tool: search
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
// different schema (limit 1-20); this transport is a plain REST full-text search
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
server.registerTool(
"search",
{
@@ -937,6 +907,10 @@ server.registerTool(
);
// Tool: docmost_transform
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
// deliberately omits the `deleteComments` schema field (comment-deletion
// guardrail) and carries a much shorter description; this transport exposes the
// full helper catalogue. Different schema, so kept per-layer.
server.registerTool(
"docmost_transform",
{
+92
View File
@@ -119,6 +119,98 @@ export const SHARED_TOOL_SPECS = {
}),
},
// --- single-block structural write (patch / insert) ---
//
// CANONICAL description merges both layers: the MCP copy's "WITHOUT resending
// the whole document" + "cheaper/safer than a full-document replace" guidance
// AND the in-app copy's "keeps the same node id" + "Reversible via page
// history" framing — nothing either side conveyed is dropped. Sibling tools are
// named in transport-neutral prose ("the page-JSON view", "a full-document
// replace") to match the rest of the registry, since the two layers expose
// those siblings under different (snake_case vs camelCase) identifiers.
patchNode: {
mcpName: 'patch_node',
inAppKey: 'patchNode',
description:
'Replace a single content block identified by its attrs.id with a new ' +
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
'keeps the same node id. Get the block id from the page-JSON view, then ' +
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
'replacing the whole document for one-block structural edits. Reversible: ' +
'the previous version is kept in page history.',
buildShape: (z) => ({
pageId: z.string().min(1).describe('ID of the page containing the block'),
nodeId: z
.string()
.min(1)
.describe(
'attrs.id of the block to replace (from the page outline or ' +
'page-JSON view)',
),
node: z
.any()
.describe(
'ProseMirror node to put in place of the node with this id, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.',
),
}),
},
insertNode: {
mcpName: 'insert_node',
inAppKey: 'insertNode',
description:
'Insert a block before/after another block (by attrs.id or anchor text) ' +
'or append it at the end (top level). For before/after you MUST provide ' +
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
'page-JSON view. Avoids resending the whole document. Can also insert ' +
'table structure: to add a tableRow, pass a tableRow node with position ' +
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
'block/cell in it, or anchorText matching the table; to add a ' +
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' +
'(anchorText only resolves top-level blocks, so it cannot target a row). ' +
"`anchorText` is matched against the block's literal rendered plain text " +
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
'text or anchorNodeId. Note: append is top-level only and rejects ' +
'structural table nodes. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Reversible via page history.',
buildShape: (z) => ({
pageId: z.string().min(1),
node: z
.any()
.describe(
'ProseMirror node to insert, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.',
),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z
.string()
.optional()
.describe('Anchor block id (for before/after).'),
anchorText: z
.string()
.optional()
.describe(
"Anchor text fragment (for before/after), matched against the " +
"block's literal rendered plain text (no markdown). Markdown/emoji " +
'are tolerated as a fallback; prefer plain text or anchorNodeId.',
),
}),
},
// --- share management ---
unsharePage: {
@@ -83,6 +83,63 @@ test("getNode builder produces exactly { pageId, nodeId }", () => {
assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]);
});
test("patchNode spec exists, merges BOTH descriptions, builds { pageId, nodeId, node }", () => {
const spec = SHARED_TOOL_SPECS.patchNode;
assert.ok(spec, "patchNode spec missing");
assert.equal(spec.mcpName, "patch_node");
assert.equal(spec.inAppKey, "patchNode");
// The canonical description must carry the key guidance from BOTH originals:
// - MCP-only: "WITHOUT resending the whole document" + the cheaper/safer note.
// - in-app-only: "keeps the same node id" + the "Reversible ... page history"
// framing the MCP copy lacked.
assert.match(spec.description, /WITHOUT resending the whole document/);
assert.match(spec.description, /Cheaper and safer/);
assert.match(spec.description, /keeps the same node id/i);
assert.match(spec.description, /Reversible/i);
assert.match(spec.description, /page history/i);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["node", "nodeId", "pageId"]);
// A minimal valid input parses (node accepts an arbitrary object via z.any()).
const parsed = z.object(shape).parse({
pageId: "p1",
nodeId: "n1",
node: { type: "paragraph" },
});
assert.equal(parsed.pageId, "p1");
assert.equal(parsed.nodeId, "n1");
});
test("insertNode spec exists, merges BOTH descriptions, builds the full anchor shape", () => {
const spec = SHARED_TOOL_SPECS.insertNode;
assert.ok(spec, "insertNode spec missing");
assert.equal(spec.mcpName, "insert_node");
assert.equal(spec.inAppKey, "insertNode");
// Canonical description must keep BOTH sides' nuance:
// - in-app-only: "EXACTLY ONE of anchorNodeId or anchorText" + "Reversible".
// - MCP-only: the table-structure (tableRow/tableCell) insertion guidance.
assert.match(spec.description, /EXACTLY ONE of anchorNodeId or anchorText/);
assert.match(spec.description, /tableRow/);
assert.match(spec.description, /append is top-level only/);
assert.match(spec.description, /Reversible via page history/);
const shape = spec.buildShape(z);
assert.deepEqual(
Object.keys(shape).sort(),
["anchorNodeId", "anchorText", "node", "pageId", "position"],
);
// before/after/append are the only accepted positions; anchors are optional.
const schema = z.object(shape);
assert.doesNotThrow(() =>
schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }),
);
assert.throws(() =>
schema.parse({ pageId: "p1", node: {}, position: "sideways" }),
);
});
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);