Compare commits

..

1 Commits

Author SHA1 Message Date
claude_code 882a6bb032 fix(editor): restore reading position only after the layout settles (#266)
Follow-up to the merged title-autofocus fix (#301). Confirmed via Chrome DevTools
on the live app: a residual reload jitter remained — the document renders
progressively (measured height 17729 -> 32185, collapsing mid-swap), and the
restore fired TOO EARLY (twice, at partial heights) because it only checked
"is the target reachable", not "has the layout settled". While the doc grew,
scroll-anchoring dragged the position and the second restore yanked it back
(the jitter), landing ~6000px off.

- restoreScrollPosition now polls the document height and restores ONCE the
  height has been stable for HEIGHT_STABLE_MS (400ms) AND the target is
  reachable; the MAX_RESTORE_WAIT_MS (5s) timeout is the only fallback that
  clamps. Removed the restoreStartRef shared budget; idempotency is now the
  `pollTimerRef !== null` guard (a running wait suppresses a second trigger).
- The two-trigger wiring (early on-mount for the offline path + post-swap) is
  unchanged; both call the now-settle-waiting, idempotent restore.
- A shadow simulation on the live page confirmed the new logic fires once,
  accurately (vs the old two-fire-plus-drift).
- Tests updated for the stable-height timing; (k) rewritten to pin the
  idempotency guard (mutation-verified); (d3) added for "waits until height
  stops changing".

Tradeoff: on progressively-rendering pages the position now appears once the
layout settles (~0.5-2s) in one smooth move, instead of an early-but-jittery,
often-inaccurate restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:49:13 +03:00
50 changed files with 417 additions and 3217 deletions
@@ -1373,10 +1373,5 @@
"Updated to the latest version": "Updated to the latest version",
"This role is no longer in the catalog": "This role is no longer in the catalog",
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
"Connecting… (read-only)": "Connecting… (read-only)",
"Apply": "Apply",
"Applied": "Applied",
"Suggestion applied": "Suggestion applied",
"Failed to apply suggestion": "Failed to apply suggestion",
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied."
"Connecting… (read-only)": "Connecting… (read-only)"
}
@@ -1229,10 +1229,5 @@
"Updated to the latest version": "Обновлено до последней версии",
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
"Connecting… (read-only)": "Подключение… (только чтение)",
"Apply": "Применить",
"Applied": "Применено",
"Suggestion applied": "Предложение применено",
"Failed to apply suggestion": "Не удалось применить предложение",
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
"Connecting… (read-only)": "Подключение… (только чтение)"
}
@@ -1,14 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Spy on the markdown renderer so we can assert it is NOT called while the block
// is collapsed (the #302 fix) and IS called once on expand. The count/fallback
// tests don't depend on real markdown, so a light stub is safe.
vi.mock("@/features/ai-chat/utils/markdown.ts", () => ({
renderChatMarkdown: vi.fn((md: string) => `<p>${md}</p>`),
}));
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
// keeps the assertions on the component's OWN count logic (authoritative vs
// estimate) rather than on translation, and mirrors the t-mock pattern used by
@@ -24,7 +17,6 @@ vi.mock("react-i18next", () => ({
import ReasoningBlock from "./reasoning-block";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
@@ -70,18 +62,4 @@ describe("ReasoningBlock", () => {
// either way the text is present in the document.
expect(screen.getByText(/reasoning/)).toBeDefined();
});
it("does not parse the reasoning markdown while collapsed; parses on expand (#302)", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
renderBlock({ text: "**bold** reasoning", tokens: 5 });
// Collapsed is the default. The expensive markdown parse (marked + DOMPurify)
// must NOT run for the hidden body — that O(n^2) re-parse on every streamed
// delta is exactly what froze the chat (#302). The collapsed body shows the
// cheap raw-text fallback instead.
expect(renderSpy).not.toHaveBeenCalled();
// Expanding parses the current text exactly once (a user-initiated click).
fireEvent.click(screen.getByRole("button"));
expect(renderSpy).toHaveBeenCalledTimes(1);
});
});
@@ -34,19 +34,15 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
// Authoritative count wins; otherwise estimate live from the streamed text.
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
const trimmed = text.trim();
// Parse the reasoning markdown ONLY while the block is expanded. Collapsed is the
// default and the common case during a long "thinking" stream: reasoning text
// streams in and grows with every throttled delta (~20Hz), so a `[trimmed]`-only
// memo re-parses the whole, ever-growing text (marked + DOMPurify) on every delta
// — an O(n²) storm that pins the main thread and freezes the chat, all for a block
// the user isn't even looking at (the html is only shown inside <Collapse in={open}>
// below). Gating on `open` skips that hidden parsing entirely; expanding parses the
// current text once (an instant, user-initiated click), and further streaming while
// open is the normal per-delta append render, like the answer.
// Memoize the markdown render so toggling `open` (or a parent re-render caused
// by an unrelated streamed delta) does not re-parse the reasoning text; it
// recomputes only when the reasoning text itself changes (while it streams in).
// collapseBlankLines collapses the blank-line gaps the model emits between every
// list item / paragraph so the reasoning renders compactly (tight lists, joined
// paragraphs) — ONLY here, not in the normal answer.
const html = useMemo(
() =>
open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : "",
[open, trimmed],
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
[trimmed],
);
return (
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { IComment } from "@/features/comment/types/comment.types";
@@ -7,15 +7,10 @@ import { IComment } from "@/features/comment/types/comment.types";
// The comment mutation hooks reach out to react-query/network — stub them so the
// component renders in isolation. We only assert the AI-badge rendering branch.
const applyMutateAsync = vi.fn();
vi.mock("@/features/comment/queries/comment-query", () => ({
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
useApplySuggestionMutation: () => ({
mutateAsync: applyMutateAsync,
isPending: false,
}),
}));
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
@@ -24,7 +19,6 @@ vi.mock("@/features/comment/components/comment-editor", () => ({
}));
import CommentListItem from "./comment-list-item";
import { canShowApply } from "@/features/comment/utils/suggestion";
const baseComment = (over?: Partial<IComment>): IComment =>
({
@@ -38,15 +32,10 @@ const baseComment = (over?: Partial<IComment>): IComment =>
...over,
}) as IComment;
function renderItem(comment: IComment, canEdit = true) {
function renderItem(comment: IComment) {
return render(
<MantineProvider>
<CommentListItem
comment={comment}
pageId="page-1"
canComment={true}
canEdit={canEdit}
/>
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
</MantineProvider>,
);
}
@@ -98,87 +87,3 @@ describe("CommentListItem — agent avatar stack", () => {
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
// only guards the insertion gate (agent → stack, user → no stack).
});
describe("CommentListItem — suggested edit (#315)", () => {
const suggestion = (over?: Partial<IComment>): IComment =>
baseComment({
selection: "old wording here",
suggestedText: "new wording here",
...over,
});
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
renderItem(suggestion(), true);
// Old text appears both as the selection quote and as the struck diff row.
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
expect(screen.getByText("new wording here")).toBeDefined();
// Apply button is present.
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
// No Applied badge yet.
expect(screen.queryByText("Applied")).toBeNull();
});
it("hides the Apply button when canEdit is false", () => {
renderItem(suggestion(), false);
// Diff still renders...
expect(screen.getByText("new wording here")).toBeDefined();
// ...but no Apply button.
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
expect(screen.getByText("Applied")).toBeDefined();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides the Apply button once the thread is resolved", () => {
renderItem(suggestion({ resolvedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("calls the apply mutation when the Apply button is clicked", () => {
applyMutateAsync.mockClear();
renderItem(suggestion(), true);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(applyMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
it("does not render the diff block for a reply (child) comment", () => {
renderItem(
suggestion({ parentCommentId: "c-0" }),
true,
);
expect(screen.queryByText("new wording here")).toBeNull();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
});
describe("canShowApply predicate", () => {
const c = (over?: Partial<IComment>): IComment =>
({ suggestedText: "x", ...over }) as IComment;
it("true when suggestion present, editable, not applied/resolved, top-level", () => {
expect(canShowApply(c(), true)).toBe(true);
});
it("false without edit permission", () => {
expect(canShowApply(c(), false)).toBe(false);
});
it("false when no suggestion", () => {
expect(canShowApply(c({ suggestedText: null }), true)).toBe(false);
});
it("false when already applied", () => {
expect(canShowApply(c({ suggestionAppliedAt: new Date() }), true)).toBe(
false,
);
});
it("false when resolved", () => {
expect(canShowApply(c({ resolvedAt: new Date() }), true)).toBe(false);
});
it("false for a reply comment", () => {
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
});
});
@@ -1,4 +1,4 @@
import { Group, Text, Box, Badge, Button } from "@mantine/core";
import { Group, Text, Box } from "@mantine/core";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
@@ -11,13 +11,11 @@ import CommentMenu from "@/features/comment/components/comment-menu";
import ResolveComment from "@/features/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks";
import {
useApplySuggestionMutation,
useDeleteCommentMutation,
useResolveCommentMutation,
useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { canShowApply } from "@/features/comment/utils/suggestion";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
@@ -26,10 +24,6 @@ interface CommentListItemProps {
comment: IComment;
pageId: string;
canComment: boolean;
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
// allowed to comment cannot apply edits).
canEdit?: boolean;
userSpaceRole?: string;
}
@@ -37,7 +31,6 @@ function CommentListItem({
comment,
pageId,
canComment,
canEdit,
userSpaceRole,
}: CommentListItemProps) {
const { t } = useTranslation();
@@ -50,7 +43,6 @@ function CommentListItem({
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const applySuggestionMutation = useApplySuggestionMutation();
const [currentUser] = useAtom(currentUserAtom);
const createdAtAgo = useTimeAgo(comment.createdAt);
@@ -103,18 +95,6 @@ function CommentListItem({
}
}
async function handleApplySuggestion() {
try {
await applySuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Errors surface via the mutation's onError notification (incl. 409).
console.error("Failed to apply suggestion:", error);
}
}
function handleCommentClick(comment: IComment) {
const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`,
@@ -231,47 +211,6 @@ function CommentListItem({
</Box>
)}
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
carrying a suggestion. Old text struck-through/red, new text green. */}
{!comment.parentCommentId && comment.suggestedText && (
<Box className={classes.suggestionBlock}>
{comment.selection && (
<Text size="xs" className={classes.suggestionOld}>
{comment.selection}
</Text>
)}
<Text size="xs" className={classes.suggestionNew}>
{comment.suggestedText}
</Text>
{comment.suggestionAppliedAt ? (
<Badge
size="sm"
color="green"
variant="light"
mt={6}
aria-label={t("Applied")}
>
{t("Applied")}
</Badge>
) : (
canShowApply(comment, canEdit) && (
<Button
size="compact-xs"
variant="light"
color="green"
mt={6}
onClick={handleApplySuggestion}
loading={applySuggestionMutation.isPending}
disabled={applySuggestionMutation.isPending}
>
{t("Apply")}
</Button>
)
)}
</Box>
)}
{!isEditing ? (
<CommentEditor defaultContent={content} editable={false} />
) : (
@@ -49,10 +49,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const [isLoading, setIsLoading] = useState(false);
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const canComment =
canEdit ||
(page?.permissions?.canEdit ?? false) ||
(space?.settings?.comments?.allowViewerComments === true);
// Separate active and resolved comments
@@ -139,7 +137,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
comment={comment}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
@@ -147,7 +144,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
/>
</div>
@@ -164,14 +160,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
)}
</Paper>
),
[
comments,
handleAddReply,
isLoading,
space?.membership?.role,
canComment,
canEdit,
],
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
);
if (isCommentsLoading) {
@@ -311,7 +300,6 @@ interface ChildCommentsProps {
parentId: string;
pageId: string;
canComment: boolean;
canEdit?: boolean;
userSpaceRole?: string;
}
const ChildComments = ({
@@ -319,7 +307,6 @@ const ChildComments = ({
parentId,
pageId,
canComment,
canEdit,
userSpaceRole,
}: ChildCommentsProps) => {
const getChildComments = useCallback(
@@ -338,7 +325,6 @@ const ChildComments = ({
comment={childComment}
pageId={pageId}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
<MemoizedChildComments
@@ -346,7 +332,6 @@ const ChildComments = ({
parentId={childComment.id}
pageId={pageId}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
</div>
@@ -21,38 +21,6 @@
box-sizing: border-box;
}
/* Suggested-edit (#315) "было → стало" diff block. */
.suggestionBlock {
margin-top: 8px;
margin-left: 6px;
padding: 6px;
border-radius: var(--mantine-radius-sm);
border: 1px solid var(--mantine-color-default-border);
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.suggestionOld {
text-decoration: line-through;
color: var(--mantine-color-red-7);
background: var(--mantine-color-red-light);
border-radius: 2px;
padding: 1px 3px;
}
.suggestionNew {
color: var(--mantine-color-green-9);
background: var(--mantine-color-green-light);
border-radius: 2px;
padding: 1px 3px;
margin-top: 4px;
}
.commentEditor {
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
@@ -5,7 +5,6 @@ import {
InfiniteData,
} from "@tanstack/react-query";
import {
applySuggestion,
createComment,
deleteComment,
getPageComments,
@@ -177,63 +176,6 @@ function updateCommentInCache(
};
}
export function useApplySuggestionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IComment, any, { commentId: string; pageId: string }>({
// No optimistic update: apply can fail with 409 (the commented text drifted),
// so we only mutate the cache once the server confirms.
mutationFn: ({ commentId }) => applySuggestion(commentId),
onSuccess: (data, variables) => {
const cache = queryClient.getQueryData(
RQ_KEY(variables.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
suggestionAppliedAt: data.suggestionAppliedAt,
suggestionAppliedById: data.suggestionAppliedById,
// The server auto-resolves the thread on apply — carry that through.
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
})),
);
}
notifications.show({ message: t("Suggestion applied") });
},
onError: (err: any) => {
// 409 => the commented text changed since the suggestion was made. Surface
// a specific message (with the current text) rather than a generic error.
const status = err?.response?.status;
const currentText = err?.response?.data?.currentText;
if (status === 409 && typeof currentText === "string") {
const shortText =
currentText.length > 80
? `${currentText.slice(0, 80)}`
: currentText;
notifications.show({
title: t(
"The commented text changed since this suggestion was made; it was not applied.",
),
message: shortText,
color: "red",
});
return;
}
notifications.show({
message: t("Failed to apply suggestion"),
color: "red",
});
},
});
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -18,13 +18,6 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
return req.data;
}
export async function applySuggestion(commentId: string): Promise<IComment> {
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
// the 409 body (`{ message, currentText }`) off err.response.data.
const req = await api.post("/comments/apply-suggestion", { commentId });
return req.data.data ?? req.data;
}
export async function updateComment(
data: Partial<IComment>,
): Promise<IComment> {
@@ -28,13 +28,6 @@ export interface IComment {
createdSource?: string;
aiChatId?: string | null;
resolvedSource?: string | null;
// Suggested-edit (#315): when an agent proposes a replacement for the
// commented `selection`, `suggestedText` holds the "стало" text. Once a user
// applies it server-side the backend stamps `suggestionAppliedAt` /
// `suggestionAppliedById` and auto-resolves the thread.
suggestedText?: string | null;
suggestionAppliedAt?: Date | string | null;
suggestionAppliedById?: 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).
@@ -1,14 +0,0 @@
import { IComment } from "@/features/comment/types/comment.types";
// Whether the suggested-edit (#315) "Apply" button should be shown for a
// comment: it must carry a suggestion, not already be applied or resolved, be a
// top-level comment, and the viewer must be able to edit the page.
export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
return Boolean(
canEdit &&
comment.suggestedText &&
!comment.suggestionAppliedAt &&
!comment.resolvedAt &&
!comment.parentCommentId,
);
}
@@ -93,9 +93,10 @@ describe("useScrollPosition", () => {
// Restore still scrolls to 500 (the captured target), NOT the clobbered 0.
// If the capture were moved into an effect (after handlers register), it
// would read the clobbered 0 and this assertion would fail.
setScrollHeight(2000); // maxScroll = 1200 >= 500
setScrollHeight(2000); // maxScroll = 1200 >= 500, held steady -> settles
act(() => {
result.current.restoreScrollPosition();
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
@@ -103,11 +104,12 @@ describe("useScrollPosition", () => {
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
setScrollHeight(2000); // tall enough + steady -> settles
const { result } = renderHook(() => useScrollPosition("once"));
act(() => {
result.current.restoreScrollPosition();
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).toHaveBeenCalledTimes(1);
@@ -119,6 +121,7 @@ describe("useScrollPosition", () => {
// the window is already at the target and does nothing.
act(() => {
result.current.restoreScrollPosition();
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).toHaveBeenCalledTimes(1);
});
@@ -126,10 +129,10 @@ describe("useScrollPosition", () => {
it("(b) does not restore when the URL has a #hash anchor", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}p2`, "500");
// Content is ALREADY tall enough (maxScroll = 2000 - 800 = 1200 >= 500), so
// without the hash guard tryRestore would call scrollTo synchronously on the
// first tick. The assertion below therefore genuinely proves the hash guard
// short-circuits before any scroll (not just that the poll has not fired).
// Content is ALREADY tall enough (maxScroll = 2000 - 800 = 1200 >= 500) and
// steady, so without the hash guard the wait would settle and scroll to the
// target. The assertion below therefore genuinely proves the hash guard
// short-circuits before any scroll (not just that the wait has not fired).
setScrollHeight(2000);
window.location.hash = "#some-heading";
@@ -168,7 +171,7 @@ describe("useScrollPosition", () => {
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
setScrollHeight(2000); // tall enough (would settle and restore, absent the wheel)
const { result } = renderHook(() => useScrollPosition("g1"));
@@ -210,8 +213,9 @@ describe("useScrollPosition", () => {
});
it("(i) a non-scroll keydown does NOT abort restore", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
setScrollHeight(2000); // tall enough + steady -> settles
const { result } = renderHook(() => useScrollPosition("i1"));
@@ -221,6 +225,7 @@ describe("useScrollPosition", () => {
});
act(() => {
result.current.restoreScrollPosition();
vi.advanceTimersByTime(500);
});
// Restore still happens: the innocuous keypress did not disable it.
@@ -229,7 +234,7 @@ describe("useScrollPosition", () => {
it("(j) a scroll keydown (Space) DOES abort restore", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
setScrollHeight(2000); // tall enough (would settle and restore, absent the scroll key)
const { result } = renderHook(() => useScrollPosition("j1"));
@@ -261,7 +266,7 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(d) scrolls to the saved Y once the content is tall enough", () => {
it("(d) scrolls to the saved Y once the height settles tall enough", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}p4`, "500");
setInnerHeight(800);
@@ -270,20 +275,53 @@ describe("useScrollPosition", () => {
const { result } = renderHook(() => useScrollPosition("p4"));
act(() => {
result.current.restoreScrollPosition();
vi.advanceTimersByTime(300);
});
// Still polling: content not laid out yet.
// Still waiting: content not laid out tall enough yet.
expect(window.scrollTo).not.toHaveBeenCalled();
// Content becomes tall enough: maxScroll = 2000 - 800 = 1200 >= 500.
// Content becomes tall enough and then holds steady past the stable window:
// maxScroll = 2000 - 800 = 1200 >= 500.
setScrollHeight(2000);
act(() => {
vi.advanceTimersByTime(100);
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(d3) waits for the height to STOP changing before restoring", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}p4b`, "500");
setInnerHeight(800);
setScrollHeight(2000); // reachable from the start (maxScroll 1200 >= 500)...
const { result } = renderHook(() => useScrollPosition("p4b"));
act(() => {
result.current.restoreScrollPosition();
});
// ...but the height keeps changing every tick, so it never settles.
act(() => {
vi.advanceTimersByTime(100);
setScrollHeight(2500);
vi.advanceTimersByTime(100);
setScrollHeight(3000);
vi.advanceTimersByTime(100);
setScrollHeight(3500);
vi.advanceTimersByTime(100);
});
expect(window.scrollTo).not.toHaveBeenCalled(); // reachable, but not settled
// Height now holds steady past HEIGHT_STABLE_MS -> restore fires (to the
// fixed target, unaffected by the taller document).
act(() => {
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(d2) clamps to the max reachable position after the timeout", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}p5`, "5000");
@@ -303,53 +341,39 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
});
it("(k) shares ONE timeout budget across re-triggers (does not restart the clock)", () => {
// The static->live editor swap re-invokes restore. The shared budget
// (restoreStartRef) must measure the MAX_RESTORE_WAIT_MS (5000) deadline
// from the FIRST trigger, not restart it on every re-trigger. This pins
// the `if (restoreStartRef.current === null)` guard: a mutant that resets
// `restoreStartRef.current = Date.now()` on every trigger would push the
// deadline out to t=8000 (3000 + 5000) and fail the t=5000 assertion below.
it("(k) a re-trigger while the wait is running does not start a second concurrent poll", () => {
// Both triggers (early on-mount + post-swap) call restore. The
// `if (pollTimerRef.current !== null) return` guard makes a re-trigger during
// an in-flight wait a no-op, so exactly ONE poll runs and scrolls exactly once.
// A mutant dropping that guard would start a second parallel poll; since the
// stubbed scrollTo never moves window.scrollY, the second poll would scroll
// again (redundancy guard sees scrollY still 0 != target) -> two calls, and
// this assertion would fail.
vi.useFakeTimers();
vi.setSystemTime(0);
window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "5000");
window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "500");
setInnerHeight(800);
setScrollHeight(1000); // maxScroll = 200, never reaches 5000 -> it polls.
setScrollHeight(100); // too short -> the first wait keeps polling (no scroll yet)
const { result } = renderHook(() => useScrollPosition("k1"));
// First trigger at t=0: starts the shared budget and begins polling.
// First trigger: starts the wait.
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
// Advance to t=3000 (still polling: content short, not yet timed out).
act(() => {
vi.advanceTimersByTime(3000);
});
expect(window.scrollTo).not.toHaveBeenCalled();
// Second trigger at t=3000 (the swap re-assert). Under the real code the
// budget is shared, so `start` stays 0; under the reset-mutant it becomes 3000.
// Second trigger while the first wait is still running: the guard suppresses it.
act(() => {
result.current.restoreScrollPosition();
});
// At t=4900 the FIRST budget has not yet elapsed (4900 - 0 < 5000): no clamp.
// Content becomes reachable and holds steady past the stable window.
setScrollHeight(2000);
act(() => {
vi.advanceTimersByTime(1900);
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).not.toHaveBeenCalled();
// At t=5000 the shared budget (measured from t=0) times out and clamps to the
// furthest reachable position (maxScroll = 200). The reset-mutant, measuring
// from t=3000, would still be waiting (5000 - 3000 = 2000 < 5000) and would
// NOT have scrolled here -> this assertion fails against that mutant.
act(() => {
vi.advanceTimersByTime(100);
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
// Exactly one scroll — the guard prevented a second concurrent poll.
expect(window.scrollTo).toHaveBeenCalledTimes(1);
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(e) never throws when storage access throws", () => {
@@ -8,6 +8,11 @@ const SAVE_THROTTLE_MS = 250;
const MAX_RESTORE_WAIT_MS = 5000;
// How often to re-check the document height while waiting for content to load.
const RESTORE_POLL_MS = 100;
// The document height must stay unchanged this long before we treat the layout
// as settled and safe to restore against — restoring while content is still
// rendering in lets scroll-anchoring drift the saved offset (and re-fire, which
// is the residual reload "jitter" this replaces).
const HEIGHT_STABLE_MS = 400;
// sessionStorage key prefix. sessionStorage survives an F5 in the same tab and
// is cleared on tab close, which is exactly the lifetime we want for an MVP
@@ -73,10 +78,13 @@ export function hasSavedReadingPosition(pageId: string): boolean {
* their place across a reload (F5) or reopening the document.
*
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
* (early, while the static/cached content is laid out, and again after the
* static->live editor swap); it is idempotent, so re-asserting the same target is
* a no-op. The two scroll mechanisms are mutually exclusive: if the URL has a
* `#hash` anchor, the existing anchor-scroll logic wins and restore is a no-op.
* (early on mount + after the static->live editor swap). It WAITS for the
* document height to stop changing (the layout to settle) and then scrolls once
* to the saved offset — so it never fires mid-render, where scroll-anchoring
* would drift the position. It is idempotent: a running wait suppresses a second
* trigger, and once positioned re-asserting is a no-op. The two scroll mechanisms
* are mutually exclusive: if the URL has a `#hash` anchor, the existing
* anchor-scroll logic wins and restore is a no-op.
*/
export function useScrollPosition(pageId: string): {
restoreScrollPosition: () => void;
@@ -104,9 +112,6 @@ export function useScrollPosition(pageId: string): {
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
const pollTimerRef = useRef<number | null>(null);
// Timestamp of the FIRST restore attempt so re-triggers (e.g. the static→live
// editor swap) share ONE bounded timeout budget instead of restarting it.
const restoreStartRef = useRef<number | null>(null);
// Capture the previously-saved value synchronously during render, before the
// effect below registers handlers that would persist the current (0) scrollY.
@@ -209,36 +214,43 @@ export function useScrollPosition(pageId: string): {
// Nothing meaningful to restore to.
if (targetY <= 0) return;
// Cancel any in-flight poll before (re)starting, so overlapping triggers can
// never run two concurrent polls against the same target.
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
// Idempotent: if a restore poll is already running, do not start a second.
// Both triggers (early + post-swap) share it; a running poll suppresses the
// second, and once positioned the redundancy guard makes it a no-op.
if (pollTimerRef.current !== null) return;
// Share one timeout budget across re-triggers instead of restarting it.
if (restoreStartRef.current === null) {
restoreStartRef.current = Date.now();
}
const start = restoreStartRef.current;
const start = Date.now();
let lastHeight = -1;
let stableSince = start;
const tryRestore = () => {
// Bail mid-poll if the reader started scrolling while we were waiting.
// Restore ONCE the document height has been stable for HEIGHT_STABLE_MS AND
// the target is reachable, so the saved pixel offset lands on the same content
// the reader left. Restoring earlier — while the doc is still rendering in
// (progressive content / static->live swap) — lets scroll-anchoring drift the
// position and makes the restore re-fire (the reload jitter). The timeout is
// the only fallback that clamps to the furthest reachable position.
const tick = () => {
// Bail mid-wait if the reader started scrolling.
if (userInteractedRef.current) {
pollTimerRef.current = null;
return;
}
const maxScroll =
document.documentElement.scrollHeight - window.innerHeight;
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
const now = Date.now();
const height = document.documentElement.scrollHeight;
if (height !== lastHeight) {
lastHeight = height;
stableSince = now;
}
const maxScroll = height - window.innerHeight;
const settled = now - stableSince >= HEIGHT_STABLE_MS;
const reachable = maxScroll >= targetY;
const timedOut = now - start >= MAX_RESTORE_WAIT_MS;
// Restore once the content is tall enough to reach the target, or bail out
// after the timeout and scroll as far as currently possible.
if (maxScroll >= targetY || timedOut) {
if ((settled && reachable) || timedOut) {
const top = Math.min(targetY, Math.max(maxScroll, 0));
// Redundancy guard: re-asserting the SAME target when already positioned
// is a no-op, so this hook can be called from multiple triggers safely.
// No-op when already there — avoids a redundant scroll and keeps the two
// triggers from double-scrolling.
if (Math.abs(window.scrollY - top) > 1) {
window.scrollTo({ top, behavior: "auto" });
}
@@ -247,10 +259,10 @@ export function useScrollPosition(pageId: string): {
}
// Stored in a ref so the effect cleanup can cancel it on unmount.
pollTimerRef.current = window.setTimeout(tryRestore, RESTORE_POLL_MS);
pollTimerRef.current = window.setTimeout(tick, RESTORE_POLL_MS);
};
tryRestore();
tick();
}, []);
return { restoreScrollPosition };
@@ -261,8 +273,9 @@ export function useScrollPosition(pageId: string): {
*
* Extracted from PageEditor so the exact restore triggers (their deps and the
* post-swap `&& editor` guard) are directly unit-testable rather than mirrored.
* Behaviour is unchanged: `restoreScrollPosition` is idempotent, so re-asserting
* the same target from either trigger is a no-op.
* `restoreScrollPosition` waits for the layout to settle and is idempotent, so
* both triggers together produce a single restore (a running wait suppresses the
* second; once positioned re-asserting is a no-op).
*
* @param pageId the page whose scroll position is persisted/restored.
* @param editor the tiptap editor instance, or `null` until it is ready.
@@ -275,16 +288,18 @@ export function useScrollRestoreOnSwap(
): void {
const { restoreScrollPosition } = useScrollPosition(pageId);
// Restore as early as the static (cached) content is laid out, before paint,
// so the reader's position is applied without a visible jump. Aborts itself if
// the reader has already started scrolling (handled inside the hook).
// Early trigger: start the restore wait on mount, so a reload that never
// reaches the live editor (offline / collab never syncs — the static cache
// stays shown) still restores once the static layout settles. The wait itself
// holds off scrolling until the height is stable, and aborts if the reader
// starts scrolling (handled inside the hook).
useLayoutEffect(() => {
restoreScrollPosition();
}, [restoreScrollPosition]);
// Re-assert once after the static -> live editor swap in case the swap reset
// the window scroll. Idempotent: a no-op when the position is already correct,
// and a no-op after the reader has interacted.
// Post-swap trigger: after the static -> live swap, (re)start the wait so it
// measures the final live layout. Idempotent: a no-op while the early wait is
// still running, and a no-op once already positioned or the reader interacted.
useLayoutEffect(() => {
if (!showStatic && editor) restoreScrollPosition();
}, [showStatic, editor, restoreScrollPosition]);
@@ -28,9 +28,11 @@ const KEY_PREFIX = "gitmost:scroll-position:";
// guard the production code directly (verified: removing `&& editor` reddens the
// first test).
//
// Both tests observe the real effect via `window.scrollTo`. The stubbed
// `window.scrollTo` never mutates `window.scrollY`, and the target is left
// unreached, so every restore invocation that passes the guard yields exactly one
// Both tests observe the real effect via `window.scrollTo`. Restore is NOT
// synchronous: it waits for the document height to settle (HEIGHT_STABLE_MS)
// before scrolling, so the tests use fake timers and advance them with a steady,
// reachable height to let the wait fire. The stubbed `window.scrollTo` never
// mutates `window.scrollY`, so every restore that settles yields exactly one
// `scrollTo` call — making the call count a faithful proxy for restore invocations.
function setScrollY(value: number): void {
@@ -80,61 +82,69 @@ describe("PageEditor scroll-restore wiring (useScrollRestoreOnSwap)", () => {
window.location.hash = "";
});
it("re-invokes restore after the swap, with the [showStatic, editor] deps/guard", () => {
// Target is immediately reachable, so each restore that passes the guard
// scrolls synchronously. `window.scrollY` stays 0 (stubbed scrollTo never
// updates it), so scrollTo is called once per effective restore — a proxy for
// the restore invocation count.
it("early trigger restores once the layout settles; post-swap re-assert gated by && editor", () => {
// Restore WAITS for the document height to settle (HEIGHT_STABLE_MS), so tests
// advance fake timers. `window.scrollY` stays 0 (stubbed scrollTo never updates
// it), so scrollTo's call count proxies the number of effective restores.
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}guard`, "500");
setInnerHeight(800);
setScrollHeight(2000); // maxScroll = 1200 >= 500: reachable, no polling.
setScrollHeight(2000); // reachable + held steady -> the wait settles
// Pre-swap: static content shown, live editor not ready. Only the early
// pre-paint restore fires; the post-swap effect's guard (!showStatic) blocks it.
// Pre-swap: the early on-mount trigger's wait settles and restores once — this
// is the offline / collab-never-syncs path (no swap needed).
const { rerender } = render(
<Host pageId="guard" showStatic={true} editor={null} />,
);
act(() => {
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).toHaveBeenCalledTimes(1);
// Collab reports synced (showStatic flips false) but the editor is not ready
// yet: the swap effect re-runs (deps [showStatic, editor] changed) but the
// `&& editor` guard must keep it a no-op. The early effect does NOT re-fire
// (its dep [restoreScrollPosition] is a stable useCallback([])).
// (Pins the guard: dropping `&& editor` would restore against a null editor,
// producing a 2nd scrollTo and failing this expectation.)
// showStatic flips false but the editor is still null: the post-swap effect
// re-runs (deps [showStatic, editor] changed) but its `&& editor` guard must
// keep it a no-op. (Dropping `&& editor` would start a fresh wait against a
// null editor and produce a 2nd scrollTo, failing this expectation.)
rerender(<Host pageId="guard" showStatic={false} editor={null} />);
act(() => {
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).toHaveBeenCalledTimes(1);
// The static -> live swap completes (showStatic false AND editor present): the
// post-swap effect re-asserts the restore exactly once more, driven solely by
// the [showStatic, editor] deps changing.
// post-swap effect re-invokes restore, whose fresh wait settles and re-asserts.
rerender(<Host pageId="guard" showStatic={false} editor={fakeEditor} />);
act(() => {
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).toHaveBeenCalledTimes(2);
});
it("the post-swap re-assert drives a REAL restore (window.scrollTo) via the hook", () => {
// End-to-end through the real useScrollPosition (inside the hook): the swap
// re-invocation is the CAUSE of the scroll (nothing scrolls before it).
it("restore waits for the height to settle before scrolling (end-to-end via the hook)", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}peg`, "500");
setInnerHeight(800);
setScrollHeight(100); // maxScroll = -700: target not reachable yet -> polls.
setScrollHeight(100); // maxScroll = -700: target not reachable yet.
// Pre-swap: the early restore runs but content is too short, so it starts
// polling (a pending timer) without scrolling. We never advance timers, so the
// early poll cannot fire on its own — isolating the swap as the sole cause.
// Mount + swap while the content is still too short: nothing scrolls, even as
// time passes — restore never fires against an unsettled/unreachable layout.
const { rerender } = render(
<Host pageId="peg" showStatic={true} editor={null} />,
);
expect(window.scrollTo).not.toHaveBeenCalled();
// The live content is now laid out tall enough to reach the target.
setScrollHeight(2000); // maxScroll = 1200 >= 500
// The static -> live swap: the post-swap useLayoutEffect re-invokes the real
// hook, whose synchronous tryRestore now reaches the target and scrolls.
act(() => {
vi.advanceTimersByTime(500);
});
act(() => {
rerender(<Host pageId="peg" showStatic={false} editor={fakeEditor} />);
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).not.toHaveBeenCalled();
// The live content finally lays out tall enough and holds steady past the
// stable window -> restore fires exactly to the saved target.
setScrollHeight(2000);
act(() => {
vi.advanceTimersByTime(500);
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
@@ -1,60 +0,0 @@
import { CollaborationGateway } from './collaboration.gateway';
import { CollaborationHandler } from './collaboration.handler';
/**
* Focused test for the COLLAB_DISABLE_REDIS fallback in handleYjsEvent.
*
* With Redis disabled the gateway builds no RedisSyncExtension, so the old code
* (`return this.redisSync?.handleEvent(...)`) returned undefined and every
* doc-mutation event silently no-opped. The fallback must instead invoke the
* handler locally against the single hocuspocus instance and return its verdict.
*
* We construct the gateway with stub extensions and an EnvironmentService whose
* isCollabDisableRedis() returns true (redisSync stays null, real hocuspocus is
* still built), then spy getHandlers so no real direct connection is opened.
*/
const stubExtension = {} as any;
function makeEnv() {
return {
getRedisUrl: () => 'redis://localhost:6379',
isCollabDisableRedis: () => true,
} as any;
}
describe('CollaborationGateway.handleYjsEvent (no-Redis fallback)', () => {
it('invokes the handler locally and returns its verdict instead of undefined', async () => {
const collabHandler = new CollaborationHandler();
const verdict = { applied: true, currentText: 'new' };
const fakeHandler = jest.fn().mockResolvedValue(verdict);
// Bypass the real direct-connection code path — assert dispatch only.
jest
.spyOn(collabHandler, 'getHandlers')
.mockReturnValue({ applyCommentSuggestion: fakeHandler } as any);
const gateway = new CollaborationGateway(
stubExtension,
stubExtension,
stubExtension,
makeEnv(),
collabHandler,
);
const payload = {
commentId: 'c1',
expectedText: 'old',
newText: 'new',
user: { id: 'u1' } as any,
};
const result = await gateway.handleYjsEvent(
'applyCommentSuggestion' as any,
'doc-1',
payload as any,
);
expect(fakeHandler).toHaveBeenCalledWith('doc-1', payload);
expect(result).toEqual(verdict);
expect(result).not.toBeUndefined();
});
});
@@ -147,41 +147,8 @@ export class CollaborationGateway {
eventName: TName,
documentName: string,
payload: Parameters<CollabEventHandlers[TName]>[1],
): ReturnType<CollabEventHandlers[TName]> {
if (this.redisSync) {
// Normal path: the Redis bridge routes the event to the instance that owns
// the document (local or another worker) and carries the handler's return
// value back to us (customEventComplete + replyId).
return this.redisSync.handleEvent(
eventName,
documentName,
payload,
) as ReturnType<CollabEventHandlers[TName]>;
}
// COLLAB_DISABLE_REDIS: there is no cross-process bridge, so a single local
// hocuspocus instance owns every document. Invoke the handler directly
// against it instead of returning undefined — otherwise doc-mutation events
// (setCommentMark / resolveCommentMark / applyCommentSuggestion) would
// silently no-op and, for suggestions, the caller could never learn the
// verdict. openDirectConnection loads the doc via the persistence extension
// if it is not already in memory.
if (this.hocuspocus) {
const handlers = this.collabEventsService.getHandlers(this.hocuspocus);
const handler = handlers[eventName] as (
documentName: string,
payload: unknown,
) => ReturnType<CollabEventHandlers[TName]>;
return handler(documentName, payload);
}
// Collaboration was never initialized (no live instance). Fail loudly rather
// than silently dropping a mutation; phase 4's caller maps this to a 5xx.
throw new Error(
`Cannot handle collaboration event "${String(
eventName,
)}": requires a live collaboration instance`,
);
) {
return this.redisSync?.handleEvent(eventName, documentName, payload);
}
openDirectConnection(documentName: string, context?: any) {
@@ -1,132 +0,0 @@
import * as Y from 'yjs';
import { CollaborationHandler } from './collaboration.handler';
import * as yjsUtil from './yjs.util';
import { User } from '@docmost/db/types/entity.types';
/**
* Unit tests for the `applyCommentSuggestion` collab handler (phase 3 of #315).
*
* The handler runs `replaceYjsMarkedText` inside the owning instance's Y
* transaction and returns the verdict to the caller. We exercise it against a
* REAL in-memory Y.Doc carrying a marked comment run, driven through a FAKE
* hocuspocus whose openDirectConnection().transact(fn) simply runs fn(doc) —
* mirroring how the real hocuspocus DirectConnection invokes the callback with
* the shared document (it does not forward the callback's return value, which is
* exactly why withYdocConnection captures it via a closure).
*/
// Build a Y.Doc with a single paragraph whose text carries a `comment` mark for
// the given commentId — the shape `replaceYjsMarkedText` walks in production.
function buildDocWithComment(text: string, commentId: string) {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment('default');
const paragraph = new Y.XmlElement('paragraph');
const xmlText = new Y.XmlText();
xmlText.insert(0, text);
xmlText.format(0, text.length, { comment: { commentId, resolved: false } });
paragraph.insert(0, [xmlText]);
fragment.insert(0, [paragraph]);
return doc;
}
// Fake hocuspocus exposing only what withYdocConnection needs: a direct
// connection whose transact() runs the callback against `doc`.
function fakeHocuspocus(doc: Y.Doc) {
const connection = {
transact: jest.fn(async (fn: (d: Y.Doc) => void) => {
fn(doc);
}),
disconnect: jest.fn(async () => {}),
};
const hocuspocus = {
openDirectConnection: jest.fn(async () => connection),
} as any;
return { hocuspocus, connection };
}
const user = { id: 'u1' } as unknown as User;
describe('CollaborationHandler.applyCommentSuggestion', () => {
it('applies the replacement and returns the verdict when the marked text matches', async () => {
const doc = buildDocWithComment('Hello world', 'c1');
const { hocuspocus, connection } = fakeHocuspocus(doc);
const handler = new CollaborationHandler();
const handlers = handler.getHandlers(hocuspocus);
const result = await handlers.applyCommentSuggestion('doc-1', {
commentId: 'c1',
expectedText: 'Hello world',
newText: 'Goodbye world',
user,
});
expect(result).toEqual({ applied: true, currentText: 'Goodbye world' });
// The mutation ran inside the transaction and hit the real doc.
expect(connection.transact).toHaveBeenCalledTimes(1);
expect(connection.disconnect).toHaveBeenCalledTimes(1);
expect(doc.getXmlFragment('default').toString()).toContain(
'Goodbye world',
);
});
it('rejects (applied=false) and returns the current text when it changed', async () => {
const doc = buildDocWithComment('Hello world', 'c1');
const { hocuspocus } = fakeHocuspocus(doc);
const handler = new CollaborationHandler();
const handlers = handler.getHandlers(hocuspocus);
const result = await handlers.applyCommentSuggestion('doc-1', {
commentId: 'c1',
expectedText: 'Stale expected text',
newText: 'Goodbye world',
user,
});
expect(result).toEqual({ applied: false, currentText: 'Hello world' });
// Nothing was replaced.
expect(doc.getXmlFragment('default').toString()).toContain(
'Hello world',
);
});
it('forwards the exact args to replaceYjsMarkedText and returns its result', async () => {
const doc = buildDocWithComment('abc', 'c9');
const { hocuspocus } = fakeHocuspocus(doc);
const spy = jest
.spyOn(yjsUtil, 'replaceYjsMarkedText')
.mockReturnValue({ applied: true, currentText: 'xyz' });
const handler = new CollaborationHandler();
const handlers = handler.getHandlers(hocuspocus);
const result = await handlers.applyCommentSuggestion('doc-1', {
commentId: 'c9',
expectedText: 'abc',
newText: 'xyz',
user,
});
expect(spy).toHaveBeenCalledWith(
doc.getXmlFragment('default'),
'c9',
'abc',
'xyz',
);
expect(result).toEqual({ applied: true, currentText: 'xyz' });
spy.mockRestore();
});
it('withYdocConnection returns the callback result (transact does not forward it)', async () => {
const doc = new Y.Doc();
const { hocuspocus } = fakeHocuspocus(doc);
const handler = new CollaborationHandler();
const value = await handler.withYdocConnection(
hocuspocus,
'doc-1',
{},
() => 42,
);
expect(value).toBe(42);
});
});
@@ -5,12 +5,7 @@ import {
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
import {
replaceYjsMarkedText,
setYjsMark,
updateYjsMarkAttribute,
YjsSelection,
} from './yjs.util';
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
@@ -78,35 +73,6 @@ export class CollaborationHandler {
},
);
},
applyCommentSuggestion: async (
documentName: string,
payload: {
commentId: string;
expectedText: string;
newText: string;
user: User;
},
): Promise<{ applied: boolean; currentText: string | null }> => {
const { commentId, expectedText, newText, user } = payload;
// Run the check-and-replace inside the owning instance's Y transaction so
// the delete+insert are atomic. The verdict from replaceYjsMarkedText is
// returned to the API-server caller (cross-process via the Redis bridge,
// or locally when Redis is disabled — see collaboration.gateway.ts).
return this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
return replaceYjsMarkedText(
fragment,
commentId,
expectedText,
newText,
);
},
);
},
updatePageContent: async (
documentName: string,
payload: {
@@ -149,28 +115,18 @@ export class CollaborationHandler {
};
}
async withYdocConnection<T>(
async withYdocConnection(
hocuspocus: Hocuspocus,
documentName: string,
context: any = {},
// `fn` MUST be synchronous: hocuspocus `connection.transact(fn)` runs fn
// synchronously and does NOT await it, so any mutations after an `await`
// inside fn would execute OUTSIDE the Yjs transaction and lose atomicity.
fn: (doc: Document) => T,
): Promise<T> {
fn: (doc: Document) => void,
): Promise<void> {
const connection = await hocuspocus.openDirectConnection(
documentName,
context,
);
try {
// hocuspocus `connection.transact(fn)` invokes fn(document) but does NOT
// forward fn's return value, so we capture it in a closure and return it
// after the transaction (and its storeDocument hooks) resolve.
let result: T;
await connection.transact((doc) => {
result = fn(doc);
});
return result!;
await connection.transact(fn);
} finally {
await connection.disconnect();
}
@@ -10,7 +10,6 @@ import {
setYjsMark,
removeYjsMarkByAttribute,
updateYjsMarkAttribute,
replaceYjsMarkedText,
type YjsSelection,
} from './yjs.util';
@@ -277,256 +276,3 @@ describe('updateYjsMarkAttribute', () => {
expect(text.toDelta()).toEqual(before);
});
});
describe('replaceYjsMarkedText', () => {
// Build a single-paragraph XmlText from runs. Insert the whole string as
// plain text FIRST, then format only the marked ranges — otherwise text
// inserted right after a marked run inherits its comment mark (Yjs carries
// formatting from the left insertion boundary).
function buildRuns(
runs: Array<{
text: string;
comment?: { commentId: string; resolved: boolean };
}>,
): { fragment: Y.XmlFragment; text: Y.XmlText } {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, runs.map((r) => r.text).join(''));
let offset = 0;
for (const run of runs) {
if (run.comment) {
text.format(offset, run.text.length, { comment: run.comment });
}
offset += run.text.length;
}
return { fragment, text };
}
// Two paragraphs, each with its own XmlText, both marked with the same
// commentId — mirrors a suggestion anchor that got split across blocks.
function buildTwoParagraphs(
a: { text: string; comment?: { commentId: string; resolved: boolean } },
b: { text: string; comment?: { commentId: string; resolved: boolean } },
): { fragment: Y.XmlFragment; textA: Y.XmlText; textB: Y.XmlText } {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const build = (seg: typeof a) => {
const para = new Y.XmlElement('paragraph');
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, seg.text);
if (seg.comment) {
text.format(0, seg.text.length, { comment: seg.comment });
}
return { para, text };
};
const pa = build(a);
const pb = build(b);
fragment.insert(0, [pa.para, pb.para]);
return { fragment, textA: pa.text, textB: pb.text };
}
it('happy path: replaces marked text with newText and keeps the comment mark', () => {
const { fragment, text } = buildRuns([
{ text: 'Hello ' },
{ text: 'world', comment: { commentId: 'c1', resolved: false } },
{ text: '!' },
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'world', 'planet');
expect(result).toEqual({ applied: true, currentText: 'planet' });
// New text carries the SAME comment mark; surrounding text is untouched.
expect(text.toDelta()).toEqual([
{ insert: 'Hello ' },
{
insert: 'planet',
attributes: { comment: { commentId: 'c1', resolved: false } },
},
{ insert: '!' },
]);
});
it('matches by commentId even when the mark is resolved', () => {
const { fragment, text } = buildWithComments([
{ text: 'foo', comment: { commentId: 'c9', resolved: true } },
]);
const result = replaceYjsMarkedText(fragment, 'c9', 'foo', 'bar');
expect(result).toEqual({ applied: true, currentText: 'bar' });
expect(text.toDelta()).toEqual([
{
insert: 'bar',
attributes: { comment: { commentId: 'c9', resolved: true } },
},
]);
});
it('changed text: marked text differs from expected → no-op, doc unchanged', () => {
const { fragment, text } = buildWithComments([
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
]);
const before = text.toDelta();
const result = replaceYjsMarkedText(fragment, 'c1', 'expected', 'new');
expect(result).toEqual({ applied: false, currentText: 'abc' });
expect(text.toDelta()).toEqual(before);
});
// F1 regression: the marked doc text is TYPOGRAPHIC (smart quotes / em-dash)
// and expectedText equals that raw typographic text — as it now does, because
// the MCP client stores the RAW anchored substring (getAnchoredText) rather
// than the agent's ASCII input. The strict `joinedText !== expectedText`
// compare must therefore MATCH and the suggestion apply (not a spurious 409).
it('typographic marked text applies when expectedText is the raw typographic text', () => {
const marked = '“hello”—world';
const { fragment, text } = buildRuns([
{ text: 'say ' },
{ text: marked, comment: { commentId: 'c1', resolved: false } },
{ text: '!' },
]);
const result = replaceYjsMarkedText(fragment, 'c1', marked, 'bye');
expect(result).toEqual({ applied: true, currentText: 'bye' });
expect(text.toDelta()).toEqual([
{ insert: 'say ' },
{
insert: 'bye',
attributes: { comment: { commentId: 'c1', resolved: false } },
},
{ insert: '!' },
]);
});
it('anchor deleted: no mark with that commentId → { applied: false, currentText: null }', () => {
const { fragment, text } = buildWithComments([
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
]);
const before = text.toDelta();
const result = replaceYjsMarkedText(fragment, 'missing', 'abc', 'new');
expect(result).toEqual({ applied: false, currentText: null });
expect(text.toDelta()).toEqual(before);
});
it('paragraph split: same commentId in two XmlText nodes → no-op, doc unchanged', () => {
const { fragment, textA, textB } = buildTwoParagraphs(
{ text: 'Hello ', comment: { commentId: 'c1', resolved: false } },
{ text: 'world', comment: { commentId: 'c1', resolved: false } },
);
const beforeA = textA.toDelta();
const beforeB = textB.toDelta();
const result = replaceYjsMarkedText(fragment, 'c1', 'Hello world', 'new');
expect(result).toEqual({ applied: false, currentText: 'Hello world' });
expect(textA.toDelta()).toEqual(beforeA);
expect(textB.toDelta()).toEqual(beforeB);
});
it('interleaved unmarked text: marked run not contiguous → no-op, doc unchanged', () => {
const { fragment, text } = buildRuns([
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
{ text: 'X' },
{ text: 'def', comment: { commentId: 'c1', resolved: false } },
]);
const before = text.toDelta();
const result = replaceYjsMarkedText(fragment, 'c1', 'abcdef', 'new');
// Joined marked text ("abcdef") is returned, but the run is not contiguous.
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
expect(text.toDelta()).toEqual(before);
});
it('preserves surrounding text and merges adjacent marked segments on apply', () => {
// The marked run itself is split into two adjacent delta segments; they must
// be treated as one contiguous run and replaced as a whole.
const { fragment, text } = buildRuns([
{ text: 'pre ' },
{ text: 'ab', comment: { commentId: 'c1', resolved: false } },
{ text: 'cd', comment: { commentId: 'c1', resolved: false } },
{ text: ' post' },
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'abcd', 'Z');
expect(result).toEqual({ applied: true, currentText: 'Z' });
expect(text.toDelta()).toEqual([
{ insert: 'pre ' },
{
insert: 'Z',
attributes: { comment: { commentId: 'c1', resolved: false } },
},
{ insert: ' post' },
]);
});
it('embed before the marked run: offset accounts for the embed unit → replaces the right text, embed intact', () => {
// "AB", then a Yjs embed (1 index unit), then marked "world". Before the
// fix the embed was skipped WITHOUT advancing offset, so the computed start
// for "world" was too low by 1 → delete/insert would have hit the embed/text
// instead of "world", mangling the embed. With the fix offset is correct.
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, 'AB');
text.insertEmbed(2, { image: { src: 'x' } });
text.insert(3, 'world');
text.format(3, 'world'.length, {
comment: { commentId: 'c1', resolved: false },
});
const result = replaceYjsMarkedText(fragment, 'c1', 'world', 'planet');
expect(result).toEqual({ applied: true, currentText: 'planet' });
// "AB" untouched, embed still present and intact, "world" → "planet"
// carrying the SAME comment mark.
expect(text.toDelta()).toEqual([
{ insert: 'AB' },
{ insert: { image: { src: 'x' } } },
{
insert: 'planet',
attributes: { comment: { commentId: 'c1', resolved: false } },
},
]);
});
it('embed inside the marked run: embed splits the run → non-contiguous → no-op, doc unchanged', () => {
// marked "abc", an embed, marked "def" — same commentId. The embed occupies
// one index unit between the two marked segments, so they are not contiguous
// → the guard rejects it and nothing is mutated (embed intact).
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, 'abc');
text.insertEmbed(3, { image: { src: 'y' } });
text.insert(4, 'def');
text.format(0, 'abc'.length, {
comment: { commentId: 'c1', resolved: false },
});
text.format(4, 'def'.length, {
comment: { commentId: 'c1', resolved: false },
});
const before = text.toDelta();
const result = replaceYjsMarkedText(fragment, 'c1', 'abcdef', 'new');
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
expect(text.toDelta()).toEqual(before);
});
});
-131
View File
@@ -133,137 +133,6 @@ export function removeYjsMarkByAttribute(
}
}
/**
* A single marked delta segment collected during the walk, together with the
* Y.XmlText node that owns it, the segment's start offset within that node,
* and the full `comment` mark attributes object (needed to re-attach the mark
* to the replacement text).
*/
type MarkedSegment = {
node: Y.XmlText;
offset: number;
length: number;
text: string;
markAttrs: Record<string, any>;
};
/**
* Atomically check-and-replace the text currently under a comment mark.
*
* Walks the fragment collecting every delta segment whose `comment` mark has the
* given commentId. The replacement is applied ONLY if the marked run is intact:
* it lives in a single Y.XmlText node, is contiguous (no unmarked text spliced
* into the middle), and its joined text still equals `expectedText`. On success
* the run is deleted and `newText` is inserted at the same offset carrying the
* SAME comment attributes, so the comment thread stays anchored to the new text.
*
* This mutates the passed fragment/text directly and does NOT open its own Y
* transaction — the caller is expected to wrap the call in connection.transact()
* so the delete+insert are atomic (mirrors updateYjsMarkAttribute's direct
* mutation style).
*
* @returns `{ applied: true, currentText: newText }` on replacement, otherwise
* `{ applied: false, currentText }` where currentText is the text currently
* under the mark (or null when the mark/anchor no longer exists).
*/
export function replaceYjsMarkedText(
fragment: Y.XmlFragment,
commentId: string,
expectedText: string,
newText: string,
): { applied: boolean; currentText: string | null } {
// 1. Collect every marked segment in document order.
const segments: MarkedSegment[] = [];
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const insert = delta.insert;
// Non-string inserts (embeds) carry no text length we can splice on.
if (typeof insert !== 'string') {
// A Yjs embed occupies one unit in the index space used by delete/
// insert/format — advance offset so a marked segment after an embed
// gets the right position (and an embed inside a marked run creates a
// gap → the contiguity guard rejects it as a changed anchor).
offset += 1;
continue;
}
const length = insert.length;
const attributes = delta.attributes ?? {};
const markAttr = attributes['comment'];
if (markAttr && markAttr.commentId === commentId) {
segments.push({
node: item,
offset,
length,
text: insert,
markAttrs: markAttr,
});
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
const joinedText = segments.map((s) => s.text).join('');
// 2a. No segments — the mark/anchor was deleted.
if (segments.length === 0) {
return { applied: false, currentText: null };
}
// 2b. Segments span more than one Y.XmlText node (paragraph split by Enter,
// or the mark bled across blocks) — treat as changed.
const node = segments[0].node;
const sameNode = segments.every((s) => s.node === node);
if (!sameNode) {
return { applied: false, currentText: joinedText };
}
// 2c. Non-contiguous within the single node: unmarked text is spliced between
// the first and last marked segment. Since collected segments are in document
// order, contiguity holds iff each segment starts where the previous ended.
let contiguous = true;
for (let i = 1; i < segments.length; i++) {
if (segments[i].offset !== segments[i - 1].offset + segments[i - 1].length) {
contiguous = false;
break;
}
}
if (!contiguous) {
return { applied: false, currentText: joinedText };
}
// 2d. The text under the mark changed.
if (joinedText !== expectedText) {
return { applied: false, currentText: joinedText };
}
// 3. All guards passed: delete the marked run and re-insert newText with the
// same comment attributes at the same offset. Atomic within the caller's
// transaction.
const start = segments[0].offset;
const len = segments.reduce((sum, s) => sum + s.length, 0);
const markAttrs = segments[0].markAttrs;
node.delete(start, len);
node.insert(start, newText, { comment: markAttrs });
return { applied: true, currentText: newText };
}
/**
* Updates a mark's attributes for all text that has the specified attribute value.
* Useful for resolving/unresolving comments by commentId.
@@ -51,7 +51,6 @@ export const AuditEvent = {
COMMENT_UPDATED: 'comment.updated',
COMMENT_RESOLVED: 'comment.resolved',
COMMENT_REOPENED: 'comment.reopened',
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
// Page
PAGE_CREATED: 'page.created',
@@ -518,20 +518,6 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
});
});
it('createComment: accepts an optional suggestedText alongside a selection', async () => {
const tools = await buildTools();
const result = await inputSchemaOf(tools.createComment).validate({
pageId: '019efe44-0000-0000-0000-000000000000',
content: 'A remark',
selection: 'титановый проводник',
suggestedText: 'медный проводник',
});
expect(result.success).toBe(true);
expect(result.value).toMatchObject({
suggestedText: 'медный проводник',
});
});
it('sharedTool-built tools (getOutline) also get the friendly message on a dropped pageId', async () => {
const tools = await buildTools();
const result = await inputSchemaOf(tools.getOutline).validate({});
@@ -173,11 +173,6 @@ 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 ' +
@@ -437,10 +432,6 @@ 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 ' +
@@ -450,10 +441,8 @@ export class AiChatToolsService {
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
"parent's anchor and take no selection. If the call fails with a " +
'"selection not found" error, retry with a corrected EXACT selection ' +
'copied verbatim from a single paragraph/block. You may also attach a ' +
'`suggestedText` proposing a replacement for the `selection` (a human ' +
'applies it from the UI); when set, the `selection` must occur exactly ' +
'once in the page. Reversible via the comment UI.',
'copied verbatim from a single paragraph/block. Reversible via the ' +
'comment UI.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().describe('The comment body as Markdown.'),
@@ -475,57 +464,24 @@ export class AiChatToolsService {
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
'of replies only).',
),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe(
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
'must be UNIQUE in the page — expand it with surrounding context ' +
'(still <=250 chars) if it occurs more than once, or the call is ' +
'refused.',
),
}),
execute: async ({
pageId,
content,
selection,
parentCommentId,
suggestedText,
}) => {
// createComment(pageId, content, type, selection?, parentCommentId?,
// suggestedText?). Top-level comments are inline and must carry a
// selection to anchor on; replies inherit the parent's anchor (no
// selection). Throwing here surfaces a tool error to the model (Vercel
// `ai` SDK) so the agent retries with a better selection — do not
// catch/suppress it.
execute: async ({ pageId, content, selection, parentCommentId }) => {
// createComment(pageId, content, type, selection?, parentCommentId?).
// Top-level comments are inline and must carry a selection to anchor
// on; replies inherit the parent's anchor (no selection). Throwing
// here surfaces a tool error to the model (Vercel `ai` SDK) so the
// agent retries with a better selection — do not catch/suppress it.
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error(
"createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.",
);
}
if (suggestedText !== undefined) {
if (parentCommentId) {
throw new Error(
"createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
);
}
if (!selection || !selection.trim()) {
throw new Error(
"createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
);
}
}
const result = await client.createComment(
pageId,
content,
'inline',
selection,
parentCommentId,
suggestedText,
);
const data = (result?.data ?? {}) as { id?: string };
return { commentId: data.id, pageId };
@@ -563,10 +519,6 @@ 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. ' +
@@ -740,25 +692,85 @@ export class AiChatToolsService {
async ({ pageId }) => await client.stashPage(pageId),
),
// 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 }) => {
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.
const parsedNode = parseNodeArg(node);
return await client.patchNode(pageId, nodeId, parsedNode);
},
),
}),
// 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 }) => {
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.
const parsedNode = parseNodeArg(node);
return await client.insertNode(pageId, parsedNode, {
position,
@@ -766,7 +778,7 @@ export class AiChatToolsService {
anchorText,
});
},
),
}),
deleteNode: sharedTool(
sharedToolSpecs.deleteNode,
@@ -809,10 +821,6 @@ 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 ' +
@@ -833,8 +841,6 @@ 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.',
@@ -849,8 +855,6 @@ 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). ' +
@@ -880,10 +884,6 @@ 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,10 +910,6 @@ 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 ' +
@@ -177,7 +177,6 @@ export interface DocmostClientLike {
type?: 'page' | 'inline',
selection?: string,
parentCommentId?: string,
suggestedText?: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
resolveComment(
commentId: string,
@@ -113,15 +113,9 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
const expectedKeys = Object.keys(shape).sort();
expect(actualKeys).toEqual(expectedKeys);
// 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).
// A non-.optional() field must surface as required in the advertised schema.
const expectedRequired = Object.entries(shape)
.filter(([, field]) => !(field instanceof z.ZodOptional))
.filter(([, field]) => !(field as z.ZodTypeAny).isOptional?.())
.map(([k]) => k)
.sort();
expect((json.required ?? []).slice().sort()).toEqual(expectedRequired);
@@ -1,119 +0,0 @@
import {
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { CommentController } from './comment.controller';
/**
* Authz-gate tests for the apply-suggestion route. Applying a suggestion
* rewrites the page text, so the route MUST call
* pageAccessService.validateCanEdit BEFORE handing off to
* commentService.applySuggestion (which performs the document mutation + stamp).
* That ordering is a security boundary: an unauthorized user must never reach
* the mutation. These tests pin it against a fully mocked controller so any
* regression that drops the gate (or reorders it after the mutation) fails here.
*/
describe('CommentController apply-suggestion authz', () => {
function makeController() {
const commentService = {
applySuggestion: jest.fn(async () => ({ id: 'c-1', applied: true })),
};
const commentRepo = { findById: jest.fn() };
const pageRepo = { findById: jest.fn() };
const spaceAbility = {} as any;
const pageAccessService = {
validateCanEdit: jest.fn(async () => undefined),
};
const wsService = {} as any;
const auditService = { log: jest.fn() };
const controller = new CommentController(
commentService as any,
commentRepo as any,
pageRepo as any,
spaceAbility,
pageAccessService as any,
wsService,
auditService as any,
);
return {
controller,
commentService,
commentRepo,
pageRepo,
pageAccessService,
};
}
const user: any = { id: 'u-1' };
const workspace: any = { id: 'ws-1' };
const provenance: any = undefined;
const dto: any = { commentId: 'c-1' };
const comment = {
id: 'c-1',
pageId: 'p-1',
spaceId: 'sp-1',
suggestedText: 'new text',
selection: 'old text',
};
const page = { id: 'p-1', spaceId: 'sp-1', deletedAt: null };
it('validateCanEdit throwing Forbidden rejects AND applySuggestion is never called', async () => {
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
makeController();
commentRepo.findById.mockResolvedValue(comment);
pageRepo.findById.mockResolvedValue(page);
pageAccessService.validateCanEdit.mockRejectedValue(
new ForbiddenException('no edit access'),
);
await expect(
controller.applySuggestion(dto, user, workspace, provenance),
).rejects.toBeInstanceOf(ForbiddenException);
// The security boundary: the mutation/stamp must NOT run for an
// unauthorized user.
expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(page, user);
expect(commentService.applySuggestion).not.toHaveBeenCalled();
});
it('happy path: validateCanEdit resolves → applySuggestion is called and its result returned', async () => {
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
makeController();
commentRepo.findById.mockResolvedValue(comment);
pageRepo.findById.mockResolvedValue(page);
const applied = { id: 'c-1', applied: true };
commentService.applySuggestion.mockResolvedValue(applied);
const result = await controller.applySuggestion(
dto,
user,
workspace,
provenance,
);
// Authorization ran before the mutation, then the service was invoked.
expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(page, user);
expect(commentService.applySuggestion).toHaveBeenCalledWith(
comment,
user,
provenance,
);
expect(result).toBe(applied);
});
it('missing comment: NotFound is thrown without authorizing or applying', async () => {
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
makeController();
commentRepo.findById.mockResolvedValue(null);
await expect(
controller.applySuggestion(dto, user, workspace, provenance),
).rejects.toBeInstanceOf(NotFoundException);
expect(pageRepo.findById).not.toHaveBeenCalled();
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
expect(commentService.applySuggestion).not.toHaveBeenCalled();
});
});
@@ -14,7 +14,6 @@ import { CommentService } from './comment.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { ResolveCommentDto } from './dto/resolve-comment.dto';
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
import { PageIdDto, CommentIdDto } from './dto/comments.input';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@@ -198,42 +197,6 @@ export class CommentController {
return updated;
}
@HttpCode(HttpStatus.OK)
@Post('apply-suggestion')
async applySuggestion(
@Body() dto: ApplySuggestionDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@AuthProvenance() provenance: AuthProvenanceData,
) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
includeResolvedBy: true,
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
const page = await this.pageRepo.findById(comment.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
// Authorize BEFORE revealing any structural detail about the comment
// (metadata-disclosure hygiene). Applying a suggestion rewrites the page
// text, so require edit access (NOT just comment access). Running this
// first means a cross-workspace user with a guessed comment UUID gets a
// uniform 403 regardless of the comment's type or suggestion state — it can
// never distinguish those before the access check. The structural 400s
// (top-level / has-a-suggested-edit) are re-checked by the service below.
await this.pageAccessService.validateCanEdit(page, user);
// The service re-validates the comment's state, returns idempotent success
// for an already-applied suggestion, and lets ConflictException (409, with
// currentText in the payload) propagate untouched.
return this.commentService.applySuggestion(comment, user, provenance);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
@@ -1,245 +0,0 @@
import {
BadRequestException,
ConflictException,
InternalServerErrorException,
} from '@nestjs/common';
import { CommentService } from './comment.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
/**
* Focused coverage for CommentService.applySuggestion (comment.service.ts).
* The service is constructed directly with jest-mocked deps (the @InjectQueue
* tokens can't be resolved by Test.createTestingModule see the sibling specs).
*
* The collaboration gateway verdict is the pivot of the whole flow, so each test
* pins a specific { applied, currentText } and asserts the DB persistence,
* auto-resolve, audit, ws broadcast, and error mapping that follow from it.
*/
describe('CommentService — applySuggestion', () => {
const UPDATED = { id: 'c-1', __updated: true } as any;
function makeService(verdict: unknown) {
const commentRepo: any = {
// Both the applied-stamp re-read and resolveComment's re-read go through
// findById; return a recognizable enriched row.
findById: jest.fn(async () => UPDATED),
updateComment: jest.fn(async () => undefined),
};
const pageRepo: any = {};
const wsService: any = { emitCommentEvent: jest.fn() };
const collaborationGateway: any = {
handleYjsEvent: jest.fn(async () => verdict),
};
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
const notificationQueue: any = { add: jest.fn(async () => undefined) };
const auditService: any = { log: jest.fn() };
const service = new CommentService(
commentRepo,
pageRepo,
wsService,
collaborationGateway,
generalQueue,
notificationQueue,
auditService,
);
return {
service,
commentRepo,
wsService,
collaborationGateway,
auditService,
};
}
const suggestionComment = (over?: Partial<any>): any => ({
id: 'c-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
creatorId: 'user-1',
parentCommentId: null,
selection: 'old text',
suggestedText: 'new text',
suggestionAppliedAt: null,
resolvedAt: null,
...over,
});
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
// Pull the updateComment patch that carries the applied stamps.
const appliedPatch = (commentRepo: any) =>
commentRepo.updateComment.mock.calls
.map((c: any[]) => c[0])
.find((patch: any) => 'suggestionAppliedAt' in patch);
it('applied=true → replaces text, persists applied stamps, auto-resolves, audits, returns updated', async () => {
const { service, commentRepo, wsService, collaborationGateway, auditService } =
makeService({ applied: true, currentText: 'new text' });
const result = await service.applySuggestion(suggestionComment(), user());
// The atomic replace was requested against the exact marked text.
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
'applyCommentSuggestion',
'page.page-1',
expect.objectContaining({
commentId: 'c-1',
expectedText: 'old text',
newText: 'new text',
user: expect.objectContaining({ id: 'user-1' }),
}),
);
// Applied stamps persisted.
const patch = appliedPatch(commentRepo);
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
expect(patch.suggestionAppliedById).toBe('user-1');
// Auto-resolved: resolveComment writes a resolvedAt/resolvedById patch too.
const resolvePatch = commentRepo.updateComment.mock.calls
.map((c: any[]) => c[0])
.find((p: any) => 'resolvedAt' in p);
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
expect(resolvePatch.resolvedById).toBe('user-1');
// Audit + broadcast + return.
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
metadata: { pageId: 'page-1' },
}),
);
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
);
expect(result).toBe(UPDATED);
});
it('applied=false but currentText === suggestedText → idempotent success (no 409)', async () => {
const { service, commentRepo, auditService } = makeService({
applied: false,
currentText: 'new text',
});
const result = await service.applySuggestion(suggestionComment(), user());
// The stamps are still persisted (reconciling a crash between the doc
// mutation and the DB write) and the call succeeds.
const patch = appliedPatch(commentRepo);
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
expect(patch.suggestionAppliedById).toBe('user-1');
expect(auditService.log).toHaveBeenCalledTimes(1);
expect(result).toBe(UPDATED);
});
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
const { service, commentRepo, auditService } = makeService({
applied: false,
currentText: 'someone else edited this',
});
const err = await service
.applySuggestion(suggestionComment(), user())
.catch((e) => e);
expect(err).toBeInstanceOf(ConflictException);
expect(err.getResponse()).toMatchObject({
currentText: 'someone else edited this',
});
// No persistence and no audit on a conflict.
expect(appliedPatch(commentRepo)).toBeUndefined();
expect(auditService.log).not.toHaveBeenCalled();
});
it('already-applied AND already-resolved → idempotent success, no collab call, no re-resolve (#315 double-click)', async () => {
const { service, collaborationGateway, commentRepo, auditService } =
makeService({ applied: true, currentText: 'new text' });
const result = await service.applySuggestion(
suggestionComment({
suggestionAppliedAt: new Date(),
resolvedAt: new Date(),
resolvedById: 'user-1',
}),
user(),
);
// Idempotent SUCCESS, not a 409. The suggestion is already applied, so the
// collaborative document is never touched again and nothing is re-stamped
// or re-resolved.
expect(result).toBe(UPDATED);
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
expect(commentRepo.updateComment).not.toHaveBeenCalled();
// Same success shape as the applied path (broadcast + audit).
expect(auditService.log).toHaveBeenCalledTimes(1);
});
it('already-applied but NOT resolved (crash window) → idempotent success, self-heals resolve, no re-apply', async () => {
const { service, collaborationGateway, commentRepo } = makeService({
applied: true,
currentText: 'new text',
});
const result = await service.applySuggestion(
suggestionComment({ suggestionAppliedAt: new Date(), resolvedAt: null }),
user(),
);
expect(result).toBe(UPDATED);
// The suggestion is NOT re-applied to the document…
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
'applyCommentSuggestion',
expect.anything(),
expect.anything(),
);
// …but the open thread is self-healed to resolved via resolveComment, which
// writes the resolve patch and updates the resolve mark.
const resolvePatch = commentRepo.updateComment.mock.calls
.map((c: any[]) => c[0])
.find((p: any) => 'resolvedAt' in p);
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
expect(resolvePatch.resolvedById).toBe('user-1');
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
'resolveCommentMark',
'page.page-1',
expect.objectContaining({ commentId: 'c-1', resolved: true }),
);
// The applied stamps are NOT re-written (already stamped).
expect(appliedPatch(commentRepo)).toBeUndefined();
});
it('rejects a comment with no suggestedText', async () => {
const { service, collaborationGateway } = makeService({
applied: true,
currentText: 'x',
});
await expect(
service.applySuggestion(
suggestionComment({ suggestedText: null }),
user(),
),
).rejects.toThrow(BadRequestException);
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
});
it('gateway returning undefined → hard error, not a silent success', async () => {
const { service, commentRepo, auditService } = makeService(undefined);
await expect(
service.applySuggestion(suggestionComment(), user()),
).rejects.toThrow(InternalServerErrorException);
// Nothing persisted, nothing audited.
expect(appliedPatch(commentRepo)).toBeUndefined();
expect(auditService.log).not.toHaveBeenCalled();
});
});
@@ -60,7 +60,6 @@ describe('CommentService — behavior', () => {
};
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
const notificationQueue: any = { add: jest.fn(async () => undefined) };
const auditService: any = { log: jest.fn() };
const service = new CommentService(
commentRepo,
@@ -69,17 +68,14 @@ describe('CommentService — behavior', () => {
collaborationGateway,
generalQueue,
notificationQueue,
auditService,
);
return {
service,
commentRepo,
wsService,
collaborationGateway,
generalQueue,
notificationQueue,
auditService,
};
}
@@ -185,95 +181,6 @@ describe('CommentService — behavior', () => {
});
});
describe('create — suggested edit validation & storage', () => {
it('rejects a suggestedText on a reply (not a top-level comment)', async () => {
const parentComment = {
id: 'parent-1',
pageId: 'page-1',
parentCommentId: null,
};
const { service, commentRepo } = makeService({ parentComment });
await expect(
service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{
content: JSON.stringify(docMentioning()),
parentCommentId: 'parent-1',
selection: 'hello world',
suggestedText: 'goodbye world',
} as any,
),
).rejects.toThrow(BadRequestException);
expect(commentRepo.insertComment).not.toHaveBeenCalled();
});
it('rejects a suggestedText without a selection', async () => {
const { service, commentRepo } = makeService();
await expect(
service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{
content: JSON.stringify(docMentioning()),
suggestedText: 'new text',
} as any,
),
).rejects.toThrow(BadRequestException);
expect(commentRepo.insertComment).not.toHaveBeenCalled();
});
it('rejects a suggestedText identical to the selection (no-op)', async () => {
const { service, commentRepo } = makeService();
await expect(
service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{
content: JSON.stringify(docMentioning()),
selection: 'same text',
// Only differs by surrounding whitespace → still a no-op after trim.
suggestedText: ' same text ',
} as any,
),
).rejects.toThrow(BadRequestException);
expect(commentRepo.insertComment).not.toHaveBeenCalled();
});
it('stores a valid suggestedText (trimmed) on the inserted row', async () => {
const { service, commentRepo } = makeService();
await service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{
content: JSON.stringify(docMentioning()),
selection: 'old text',
type: 'inline',
suggestedText: ' new text ',
} as any,
);
const insertArg = commentRepo.insertComment.mock.calls[0][0];
expect(insertArg.suggestedText).toBe('new text');
expect(insertArg.selection).toBe('old text');
});
it('leaves suggestedText null for an ordinary comment', async () => {
const { service, commentRepo } = makeService();
await service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{ content: JSON.stringify(docMentioning()) } as any,
);
const insertArg = commentRepo.insertComment.mock.calls[0][0];
expect(insertArg.suggestedText).toBeNull();
});
});
describe('resolveComment — provenance & resolve notifications', () => {
it('stamps resolvedSource:"agent" when an agent resolves', async () => {
const { service, commentRepo } = makeService();
@@ -61,8 +61,6 @@ describe('CommentService — broadcast carries the agent avatar stack', () => {
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
const notificationQueue: any = { add: jest.fn(async () => undefined) };
const auditService: any = { log: jest.fn() };
const service = new CommentService(
commentRepo,
pageRepo,
@@ -70,7 +68,6 @@ describe('CommentService — broadcast carries the agent avatar stack', () => {
collaborationGateway,
generalQueue,
notificationQueue,
auditService,
);
return { service, commentRepo, wsService };
@@ -14,7 +14,6 @@ describe('CommentService', () => {
{} as any, // collaborationGateway
{} as any, // generalQueue
{} as any, // notificationQueue
{} as any, // auditService
);
});
+1 -199
View File
@@ -1,10 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
@@ -29,11 +26,6 @@ import {
AuthProvenanceData,
agentSourceFields,
} from '../../common/decorators/auth-provenance.decorator';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@Injectable()
export class CommentService {
@@ -48,7 +40,6 @@ export class CommentService {
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
private notificationQueue: Queue,
@Inject(AUDIT_SERVICE) private auditService: IAuditService,
) {}
async findById(commentId: string) {
@@ -87,58 +78,15 @@ export class CommentService {
}
}
// Do NOT lossily truncate at 250: for a suggestion the client sends the RAW
// anchored document substring (the exact text under the comment mark) as the
// selection, which can be LONGER than the agent's <=250-char typed input
// (normalization collapses whitespace/typographic runs, so the raw span can
// exceed the normalized selection). Truncating it shorter than the mark span
// would break the apply-time equality check and make the suggestion
// un-appliable. Keep a generous 2000-char safety bound (matching
// suggestedText) so a legitimate anchored substring is never cut.
const selection = createCommentDto?.selection?.substring(0, 2000) ?? null;
// A suggested edit rewrites the exact text under an inline comment mark, so
// it is only meaningful on a top-level inline comment that carries a
// selection, and only if the suggestion actually changes that text.
let suggestedText: string | null = null;
if (
createCommentDto.suggestedText !== undefined &&
createCommentDto.suggestedText !== null
) {
if (createCommentDto.parentCommentId) {
throw new BadRequestException(
'A suggested edit can only be attached to a top-level comment, not a reply',
);
}
if (!selection || selection.trim().length === 0) {
throw new BadRequestException(
'A suggested edit requires an inline comment with a non-empty text selection',
);
}
const trimmed = createCommentDto.suggestedText.trim();
if (trimmed.length === 0) {
throw new BadRequestException('A suggested edit cannot be empty');
}
// A no-op suggestion (identical to the selection) is meaningless and would
// make "apply" indistinguishable from "already applied".
if (trimmed === selection.trim()) {
throw new BadRequestException(
'A suggested edit must differ from the selected text',
);
}
suggestedText = trimmed;
}
const inserted = await this.commentRepo.insertComment({
pageId: page.id,
content: commentContent,
selection,
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
type: createCommentDto.type ?? 'page',
parentCommentId: createCommentDto?.parentCommentId,
creatorId: user.id,
workspaceId: workspaceId,
spaceId: page.spaceId,
suggestedText,
// Agent-edit provenance: the user stays creatorId; this only annotates the
// source. Normal user requests leave the column default ('user').
...agentSourceFields(provenance, 'createdSource', 'aiChatId'),
@@ -351,152 +299,6 @@ export class CommentService {
return updatedComment;
}
/**
* Apply the suggested edit carried by a top-level inline comment: atomically
* replace the text under the comment mark in the collaborative document with
* the comment's suggestedText, then stamp the applied fields and auto-resolve
* the thread. The controller authorizes (validateCanEdit); this re-checks the
* comment's own state so the invariant holds regardless of caller.
*/
async applySuggestion(
comment: Comment,
user: User,
provenance?: AuthProvenanceData,
): Promise<Comment> {
// Structural guards.
if (comment.parentCommentId) {
throw new BadRequestException(
'Only a top-level comment can carry a suggested edit',
);
}
if (!comment.suggestedText) {
throw new BadRequestException('This comment has no suggested edit to apply');
}
// State guards. Order matters — the already-applied check precedes the
// resolved check because an applied comment is normally also resolved.
//
// Already applied → IDEMPOTENT SUCCESS (issue #315 DoD: double-click /
// two-user race → idempotent "already applied", NOT a 409). The suggestion
// is already in the document, so do NOT call the collab gateway again.
// finalizeAppliedSuggestion re-fetches/broadcasts the same success shape as
// the applied branch and, when the thread is still open (the rare "applied
// but not resolved" crash window), self-heals it via resolveComment.
if (comment.suggestionAppliedAt) {
return this.finalizeAppliedSuggestion(comment, user, provenance);
}
// Not-yet-applied on a resolved thread → reject. The client hides the apply
// button once a thread is resolved; this is the defensive server check.
if (comment.resolvedAt) {
throw new BadRequestException(
'Cannot apply a suggested edit on a resolved comment thread',
);
}
// Derive the document name the same way create()/resolveComment() do for
// the comment marks: `page.${pageId}`.
const documentName = `page.${comment.pageId}`;
let verdict: { applied: boolean; currentText: string | null } | undefined;
try {
verdict = await this.collaborationGateway.handleYjsEvent(
'applyCommentSuggestion',
documentName,
{
commentId: comment.id,
expectedText: comment.selection,
newText: comment.suggestedText,
user,
},
);
} catch (error) {
// A throwing gateway (or the phase-3 fallback failing) is a hard error —
// never silently succeed, the document may or may not have changed.
this.logger.error(
`Failed to apply suggested edit for comment ${comment.id}`,
error,
);
throw new InternalServerErrorException('Failed to apply the suggested edit');
}
if (!verdict) {
// Should not happen given the phase-3 fallback; treat as a hard error
// rather than assuming success.
throw new InternalServerErrorException('Failed to apply the suggested edit');
}
if (verdict.applied === true) {
return this.finalizeAppliedSuggestion(comment, user, provenance);
}
// Idempotent branch: the mutation didn't run now, but the text under the
// mark is ALREADY the suggested text (double-click, two-user race, or a
// crash between the doc mutation and the DB write). Reconcile the DB /
// resolved state and report success — do NOT 409.
if (
verdict.applied === false &&
verdict.currentText === comment.suggestedText
) {
return this.finalizeAppliedSuggestion(comment, user, provenance);
}
// The commented text changed since the suggestion was made. Surface the
// current text so the client can tell the user what it is now.
throw new ConflictException({
message:
'The commented text changed since this suggestion was made; it was not applied.',
currentText: verdict.currentText,
});
}
/**
* Persist the applied stamps (idempotently), auto-resolve the thread and
* broadcast + audit the applied suggestion. Shared by the applied and the
* idempotent "already-applied" branches of applySuggestion.
*/
private async finalizeAppliedSuggestion(
comment: Comment,
user: User,
provenance?: AuthProvenanceData,
): Promise<Comment> {
if (!comment.suggestionAppliedAt) {
await this.commentRepo.updateComment(
{
suggestionAppliedAt: new Date(),
suggestionAppliedById: user.id,
},
comment.id,
);
}
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
// broadcast and the resolve notification. The guard above guarantees the
// thread was open when we entered, but stay defensive on re-entry.
if (!comment.resolvedAt) {
await this.resolveComment(comment, true, user, provenance);
}
const updatedComment = await this.commentRepo.findById(comment.id, {
includeCreator: true,
includeResolvedBy: true,
});
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment: updatedComment,
});
this.auditService.log({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
});
return updatedComment;
}
private async queueCommentNotification(
content: any,
oldMentionIds: string[],
@@ -1,6 +0,0 @@
import { IsUUID } from 'class-validator';
export class ApplySuggestionDto {
@IsUUID()
commentId: string;
}
@@ -1,12 +1,4 @@
import {
IsIn,
IsJSON,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
import { z } from 'zod';
const yjsIdSchema = z.object({
@@ -33,15 +25,8 @@ export class CreateCommentDto {
@IsJSON()
content: any;
// The agent tool caps what it TYPES at 250 chars, but for a suggestion the
// client resolves and sends the RAW anchored document substring (the exact
// text under the mark), which can be longer once normalization is undone. Bound
// the stored value at 2000 (matching suggestedText) so a legitimate anchored
// substring is never rejected — the service used to lossily truncate at 250,
// which broke the apply-time equality check.
@IsOptional()
@IsString()
@MaxLength(2000)
selection: string;
@IsOptional()
@@ -58,12 +43,4 @@ export class CreateCommentDto {
anchor: any;
head: any;
};
// Optional suggested replacement for the selected text (a "suggested edit").
// Only valid on a top-level inline comment that carries a non-empty selection;
// enforced in CommentService.create.
@IsOptional()
@IsString()
@MaxLength(2000)
suggestedText?: string;
}
@@ -1,26 +0,0 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Agent comment suggestions (#315): a comment may carry a proposed replacement
// for its anchored `selection`, which a human applies via the comment UI.
await db.schema
.alterTable('comments')
// The proposed replacement text (plain text). NULL for ordinary comments.
.addColumn('suggested_text', 'text')
// When the suggestion was applied (NULL until applied).
.addColumn('suggestion_applied_at', 'timestamptz')
// Who applied it (NULL until applied).
.addColumn('suggestion_applied_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('comments')
.dropColumn('suggested_text')
.dropColumn('suggestion_applied_at')
.dropColumn('suggestion_applied_by_id')
.execute();
}
-3
View File
@@ -173,9 +173,6 @@ export interface Comments {
resolvedSource: string | null;
selection: string | null;
spaceId: string;
suggestedText: string | null;
suggestionAppliedAt: Timestamp | null;
suggestionAppliedById: string | null;
type: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
+11 -86
View File
@@ -17,7 +17,7 @@ import { withPageLock } from "./lib/page-lock.js";
import { applyTextEdits, } from "./lib/json-edit.js";
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
import { diffDocs, summarizeChange } from "./lib/diff.js";
import { applyAnchorInDoc, canAnchorInDoc, countAnchorMatches, getAnchoredText, } from "./lib/comment-anchor.js";
import { applyAnchorInDoc, canAnchorInDoc } from "./lib/comment-anchor.js";
import { blockText, walk, getList, insertMarkerAfter, setCalloutRange, noteItem, mdToInlineNodes, commentsToFootnotes, canonicalizeFootnotes, insertInlineFootnote, } from "./lib/transforms.js";
import vm from "node:vm";
// Supported image types, kept as two lookup tables so both a local file
@@ -1912,21 +1912,9 @@ export class DocmostClient {
* an orphan, unanchored comment behind. Replies (parentCommentId set) inherit
* their parent's anchor: they take NO selection and are not anchored.
*/
async createComment(pageId, content, type = "page", selection, parentCommentId, suggestedText) {
async createComment(pageId, content, type = "page", selection, parentCommentId) {
await this.ensureAuthenticated();
const isReply = !!parentCommentId;
const hasSuggestion = suggestedText !== undefined && suggestedText !== null;
// Defense in depth mirroring the server DTO/service: a suggested edit rewrites
// the exact anchored text, so it is only meaningful on a top-level inline
// comment that carries a selection.
if (hasSuggestion) {
if (isReply) {
throw new Error("create_comment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.");
}
if (!selection || !selection.trim()) {
throw new Error("create_comment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.");
}
}
// Only top-level comments are inline-anchored, so they are stored as
// "inline". Replies carry no inline selection, so they keep the historical
// general ("page") type — both backward-compatible and semantically correct.
@@ -1936,16 +1924,6 @@ export class DocmostClient {
if (!isReply && (!selection || !selection.trim())) {
throw new Error("create_comment: an inline 'selection' (exact text to anchor on) is required for a top-level comment");
}
// For a SUGGESTION, the value we store as the comment's `selection` must be
// the RAW document substring the mark lands on (typographic quotes/dashes,
// nbsp, collapsed whitespace), NOT the agent's ASCII input. The anchor is
// placed via normalization, so when the doc was auto-converted to
// typographic the raw substring differs from the agent input; apply-time
// compares the stored selection to the marked doc text STRICTLY, so storing
// the raw substring is what makes "Apply" succeed instead of a spurious 409.
// Captured in the pre-check below (which already reads the page) and used as
// payload.selection. Ordinary comments keep sending the raw agent selection.
let anchoredSelection = null;
// For a top-level comment, fail BEFORE creating anything when the selection
// is not present in the persisted document — this avoids leaving an orphan
// comment + notification behind. A read failure (network) is non-fatal: the
@@ -1953,38 +1931,16 @@ export class DocmostClient {
if (!isReply && selection) {
try {
const page = await this.getPageJson(pageId);
if (hasSuggestion) {
// A suggestion's anchor MUST be unambiguous: applying it rewrites the
// exact anchored text, and ordinary anchoring silently takes the first
// occurrence, so 0 matches -> not found and >=2 -> ambiguous, both
// rejected BEFORE creating the comment.
const matches = countAnchorMatches(page.content, selection);
if (matches === 0) {
throw new Error("create_comment: could not find the selection text in the page to anchor the comment. " +
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).");
}
if (matches >= 2) {
throw new Error(`create_comment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` +
"A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " +
"(still <=250 chars) so it appears exactly once.");
}
// Exactly one match: capture the RAW anchored substring to store as the
// comment selection (so apply-time equality holds). If this returns
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
// the raw agent selection below rather than crash.
anchoredSelection = getAnchoredText(page.content, selection);
}
else if (!canAnchorInDoc(page.content, selection)) {
if (!canAnchorInDoc(page.content, selection)) {
throw new Error("create_comment: could not find the selection text in the page to anchor the comment. " +
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).");
}
}
catch (e) {
// Rethrow our own "not found"/"ambiguous" errors; swallow read/network
// errors so the live anchor step can still try (and enforce) anchoring.
// Rethrow our own "not found" error; swallow read/network errors so the
// live anchor step can still try (and enforce) the anchoring.
if (e instanceof Error &&
(e.message.startsWith("create_comment: could not find the selection") ||
e.message.startsWith("create_comment: the suggestion's selection is ambiguous"))) {
e.message.startsWith("create_comment: could not find the selection")) {
throw e;
}
if (process.env.DEBUG) {
@@ -2002,19 +1958,10 @@ export class DocmostClient {
content: JSON.stringify(jsonContent),
type: effectiveType,
};
// For a suggestion, store the RAW anchored substring (anchoredSelection) so
// the stored selection === the text under the mark === apply-time
// expectedText. Ordinary comments (and the null fallback) keep the raw
// agent selection — their selection is only display/anchor and never used
// by apply, so their behavior is unchanged.
if (!isReply && selection)
payload.selection = anchoredSelection ?? selection;
payload.selection = selection;
if (parentCommentId)
payload.parentCommentId = parentCommentId;
// Only a top-level inline comment (with a selection) may carry a suggestion.
if (!isReply && selection && hasSuggestion) {
payload.suggestedText = suggestedText;
}
const response = await this.client.post("/comments/create", payload);
const comment = response.data.data || response.data;
const markdown = comment.content
@@ -2041,34 +1988,15 @@ export class DocmostClient {
throw new Error("create_comment: the server returned no comment id, so the comment could not be anchored");
}
let anchored = false;
// Set inside the transform when a suggestion's live anchor is ambiguous
// (>=2 occurrences), so the rollback path can surface the right error.
let ambiguousInLiveDoc = false;
try {
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260). The
// /comments/create REST call above keeps the agent-supplied id.
const pageUuid = await this.resolvePageId(pageId);
// Route through the mutatePage seam (not the free function) so this
// wrapper's uniqueness gate + rollback can be unit-tested without a live
// Hocuspocus collab socket.
const mutation = await this.mutatePage(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
const doc = liveDoc && liveDoc.type === "doc"
? liveDoc
: { type: "doc", content: [] };
if (hasSuggestion) {
// Authoritative uniqueness check against the LIVE document: a
// suggestion must anchor to EXACTLY ONE occurrence, otherwise
// "Apply" would rewrite the wrong/ambiguous text. If the live doc
// no longer has exactly one occurrence (it changed since the
// pre-check), abort so the just-created comment is rolled back
// rather than mis-anchored to the first occurrence.
const liveCount = countAnchorMatches(doc, selection);
if (liveCount !== 1) {
ambiguousInLiveDoc = liveCount >= 2;
return null;
}
}
if (applyAnchorInDoc(doc, selection, newCommentId)) {
anchored = true;
return doc;
@@ -2086,13 +2014,10 @@ export class DocmostClient {
throw e;
}
if (!anchored) {
// Mutation aborted because the selection was not found (or, for a
// suggestion, was ambiguous) in the live document. Roll back the comment
// and surface a hard error.
// Mutation aborted because the selection was not found in the live
// document. Roll back the comment and surface a hard error.
await this.safeDeleteComment(newCommentId);
throw new Error(ambiguousInLiveDoc
? "create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique."
: "create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back");
throw new Error("create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back");
}
result.anchored = true;
return result;
+55 -58
View File
@@ -76,10 +76,6 @@ 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 " +
@@ -147,10 +143,6 @@ 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 " +
@@ -167,8 +159,6 @@ 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 " +
@@ -184,8 +174,6 @@ 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 " +
@@ -329,17 +317,62 @@ export function createDocmostMcpServer(config) {
},
};
});
// 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 }) => {
// 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 }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
return jsonContent(result);
});
// 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 }) => {
// 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 }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
position,
@@ -420,10 +453,6 @@ 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>.",
@@ -510,9 +539,6 @@ 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 " +
@@ -520,10 +546,7 @@ export function createDocmostMcpServer(config) {
"A top-level comment REQUIRES an exact `selection`; if the selection " +
"cannot be found in the page the call fails (no orphan comment is left). " +
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
"selection. You may also attach a `suggestedText` proposing a replacement " +
"for the `selection`; a human applies (or rejects) it from the UI. When " +
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
"page — expand it with surrounding context if it is ambiguous.",
"selection.",
inputSchema: {
pageId: z.string().describe("ID of the page to comment on"),
content: z.string().min(1).describe("Comment content in Markdown format"),
@@ -540,30 +563,12 @@ export function createDocmostMcpServer(config) {
.string()
.optional()
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe("Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
"applied by a human via the UI (never auto-applied). REQUIRES a " +
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
"be UNIQUE in the page — expand it with surrounding context (still " +
"<=250 chars) if it occurs more than once, or the call is refused."),
},
}, async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
}, async ({ pageId, content, selection, parentCommentId }) => {
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error("create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.");
}
if (suggestedText !== undefined) {
if (parentCommentId) {
throw new Error("create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.");
}
if (!selection || !selection.trim()) {
throw new Error("create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.");
}
}
const result = await docmostClient.createComment(pageId, content, "inline", selection, parentCommentId, suggestedText);
const result = await docmostClient.createComment(pageId, content, "inline", selection, parentCommentId);
return jsonContent(result);
});
// Tool: update_comment
@@ -647,10 +652,6 @@ 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).",
@@ -671,10 +672,6 @@ 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 " +
-132
View File
@@ -148,67 +148,6 @@ export function findAnchorInBlock(blockContent, selection) {
}
return null;
}
/**
* Reconstruct the RAW text spanned by an AnchorMatch inside one block's
* `content` array. `startChild..endChild` are all text nodes (guaranteed by
* findAnchorInBlock, which only builds runs of `text` nodes), so concatenate
* each node's text slice: from `startOffset` on the first node, up to
* `endOffset` on the last, and the whole `.text` for any node fully inside the
* range. Mirrors spliceCommentMark's per-node slicing so the string returned
* here is EXACTLY the characters the comment mark will cover.
*/
function reconstructRawText(blockContent, match) {
const { startChild, startOffset, endChild, endOffset } = match;
let out = "";
for (let k = startChild; k <= endChild; k++) {
const n = blockContent[k];
const text = typeof n.text === "string" ? n.text : "";
const sliceStart = k === startChild ? startOffset : 0;
const sliceEnd = k === endChild ? endOffset : text.length;
out += text.slice(sliceStart, sliceEnd);
}
return out;
}
/**
* Return the RAW document substring that `selection` would anchor to the exact
* characters the comment mark will cover or `null` when the selection cannot
* be anchored anywhere in `doc`.
*
* This mirrors canAnchorInDoc / applyAnchorInDoc EXACTLY (same depth-first,
* document-order traversal and the same findAnchorInBlock match on the FIRST
* matching block), but instead of a boolean / an in-place mutation it
* reconstructs the raw text spanned by the matched range. Because
* findAnchorInBlock maps the normalized selection back to raw text-node
* positions, the returned string is the document's ORIGINAL characters (smart
* quotes, em-dashes, nbsp, collapsed whitespace) NOT the normalized ASCII
* agent input.
*
* Callers store THIS as the comment's `selection` so the stored value equals the
* text actually under the mark, which is what the apply-suggestion equality
* check (replaceYjsMarkedText's `joinedText !== expectedText`) compares against.
* Without it a suggestion whose anchor only matched via normalization would be
* un-appliable (spurious 409).
*/
export function getAnchoredText(doc, selection) {
const visit = (node, depth) => {
if (depth > MAX_DEPTH || !node || typeof node !== "object")
return null;
if (!Array.isArray(node.content))
return null;
const match = findAnchorInBlock(node.content, selection);
if (match)
return reconstructRawText(node.content, match);
for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
const found = visit(child, depth + 1);
if (found !== null)
return found;
}
}
return null;
};
return visit(doc, 0);
}
/**
* Depth-first, document-order check for whether `selection` can be anchored
* anywhere in `doc`. At each node with an array `content`, first try to match
@@ -271,77 +210,6 @@ function spliceCommentMark(blockContent, match, commentId) {
}
blockContent.splice(startChild, endChild - startChild + 1, ...fragments);
}
/**
* Count how many times `selection` occurs across the whole document, using the
* same normalization and run-matching as findAnchorInBlock but WITHOUT stopping
* at the first hit: every non-overlapping occurrence within each block's text
* runs is counted and summed across all blocks (depth-first, the same traversal
* as canAnchorInDoc).
*
* This is the uniqueness gate for SUGGESTIONS: because applying a suggestion
* rewrites the exact anchored text, an ambiguous anchor (>1 occurrence) would
* silently edit the wrong place, so a suggestion is only allowed when this
* returns exactly 1. Ordinary comments keep first-occurrence anchoring and do
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
* occurrences inside one block are correctly reported as 2.)
*/
export function countAnchorMatches(doc, selection) {
const normSel = normalizeForMatch(selection).norm.trim();
if (normSel.length === 0)
return 0;
// Count non-overlapping occurrences of the normalized selection within a
// single block's direct content, matching findAnchorInBlock's run building.
const countInBlock = (blockContent) => {
if (!Array.isArray(blockContent))
return 0;
let count = 0;
let i = 0;
while (i < blockContent.length) {
const node = blockContent[i];
if (!node || typeof node !== "object" || node.type !== "text") {
i++;
continue;
}
// Accumulate a maximal run of consecutive text nodes.
let rawRun = "";
let j = i;
while (j < blockContent.length) {
const n = blockContent[j];
if (!n || typeof n !== "object" || n.type !== "text")
break;
rawRun += typeof n.text === "string" ? n.text : "";
j++;
}
const norm = normalizeForMatch(rawRun).norm;
// Count every non-overlapping occurrence in this run.
let from = 0;
for (;;) {
const idx = norm.indexOf(normSel, from);
if (idx === -1)
break;
count++;
from = idx + normSel.length;
}
i = j > i ? j : i + 1;
}
return count;
};
let total = 0;
const visit = (node, depth) => {
if (depth > MAX_DEPTH || !node || typeof node !== "object")
return;
if (!Array.isArray(node.content))
return;
total += countInBlock(node.content);
for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
visit(child, depth + 1);
}
}
};
visit(doc, 0);
return total;
}
/**
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
* whose content matches `selection`, splice the comment mark across the matched
-5
View File
@@ -70,11 +70,6 @@ export function filterComment(comment, markdownContent) {
editedAt: comment.editedAt || null,
resolvedAt: comment.resolvedAt || null,
resolvedById: comment.resolvedById || null,
// Suggestion state: the proposed replacement text (if any) and, once a human
// applies it via the UI, when and by whom.
suggestedText: comment.suggestedText || null,
suggestionAppliedAt: comment.suggestionAppliedAt || null,
suggestionAppliedById: comment.suggestionAppliedById || null,
};
}
export function filterSearchResult(result) {
-80
View File
@@ -80,86 +80,6 @@ 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',
+10 -103
View File
@@ -56,12 +56,7 @@ import {
} from "./lib/json-edit.js";
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
import { diffDocs, summarizeChange } from "./lib/diff.js";
import {
applyAnchorInDoc,
canAnchorInDoc,
countAnchorMatches,
getAnchoredText,
} from "./lib/comment-anchor.js";
import { applyAnchorInDoc, canAnchorInDoc } from "./lib/comment-anchor.js";
import {
blockText,
walk,
@@ -2400,28 +2395,10 @@ export class DocmostClient {
type: "page" | "inline" = "page",
selection?: string,
parentCommentId?: string,
suggestedText?: string,
) {
await this.ensureAuthenticated();
const isReply = !!parentCommentId;
const hasSuggestion =
suggestedText !== undefined && suggestedText !== null;
// Defense in depth mirroring the server DTO/service: a suggested edit rewrites
// the exact anchored text, so it is only meaningful on a top-level inline
// comment that carries a selection.
if (hasSuggestion) {
if (isReply) {
throw new Error(
"create_comment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.",
);
}
if (!selection || !selection.trim()) {
throw new Error(
"create_comment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.",
);
}
}
// Only top-level comments are inline-anchored, so they are stored as
// "inline". Replies carry no inline selection, so they keep the historical
// general ("page") type — both backward-compatible and semantically correct.
@@ -2434,17 +2411,6 @@ export class DocmostClient {
);
}
// For a SUGGESTION, the value we store as the comment's `selection` must be
// the RAW document substring the mark lands on (typographic quotes/dashes,
// nbsp, collapsed whitespace), NOT the agent's ASCII input. The anchor is
// placed via normalization, so when the doc was auto-converted to
// typographic the raw substring differs from the agent input; apply-time
// compares the stored selection to the marked doc text STRICTLY, so storing
// the raw substring is what makes "Apply" succeed instead of a spurious 409.
// Captured in the pre-check below (which already reads the page) and used as
// payload.selection. Ordinary comments keep sending the raw agent selection.
let anchoredSelection: string | null = null;
// For a top-level comment, fail BEFORE creating anything when the selection
// is not present in the persisted document — this avoids leaving an orphan
// comment + notification behind. A read failure (network) is non-fatal: the
@@ -2452,45 +2418,18 @@ export class DocmostClient {
if (!isReply && selection) {
try {
const page = await this.getPageJson(pageId);
if (hasSuggestion) {
// A suggestion's anchor MUST be unambiguous: applying it rewrites the
// exact anchored text, and ordinary anchoring silently takes the first
// occurrence, so 0 matches -> not found and >=2 -> ambiguous, both
// rejected BEFORE creating the comment.
const matches = countAnchorMatches(page.content, selection);
if (matches === 0) {
throw new Error(
"create_comment: could not find the selection text in the page to anchor the comment. " +
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
);
}
if (matches >= 2) {
throw new Error(
`create_comment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` +
"A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " +
"(still <=250 chars) so it appears exactly once.",
);
}
// Exactly one match: capture the RAW anchored substring to store as the
// comment selection (so apply-time equality holds). If this returns
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
// the raw agent selection below rather than crash.
anchoredSelection = getAnchoredText(page.content, selection);
} else if (!canAnchorInDoc(page.content, selection)) {
if (!canAnchorInDoc(page.content, selection)) {
throw new Error(
"create_comment: could not find the selection text in the page to anchor the comment. " +
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
);
}
} catch (e) {
// Rethrow our own "not found"/"ambiguous" errors; swallow read/network
// errors so the live anchor step can still try (and enforce) anchoring.
// Rethrow our own "not found" error; swallow read/network errors so the
// live anchor step can still try (and enforce) the anchoring.
if (
e instanceof Error &&
(e.message.startsWith("create_comment: could not find the selection") ||
e.message.startsWith(
"create_comment: the suggestion's selection is ambiguous",
))
e.message.startsWith("create_comment: could not find the selection")
) {
throw e;
}
@@ -2513,18 +2452,8 @@ export class DocmostClient {
content: JSON.stringify(jsonContent),
type: effectiveType,
};
// For a suggestion, store the RAW anchored substring (anchoredSelection) so
// the stored selection === the text under the mark === apply-time
// expectedText. Ordinary comments (and the null fallback) keep the raw
// agent selection — their selection is only display/anchor and never used
// by apply, so their behavior is unchanged.
if (!isReply && selection)
payload.selection = anchoredSelection ?? selection;
if (!isReply && selection) payload.selection = selection;
if (parentCommentId) payload.parentCommentId = parentCommentId;
// Only a top-level inline comment (with a selection) may carry a suggestion.
if (!isReply && selection && hasSuggestion) {
payload.suggestedText = suggestedText;
}
const response = await this.client.post("/comments/create", payload);
const comment = response.data.data || response.data;
@@ -2556,18 +2485,12 @@ export class DocmostClient {
);
}
let anchored = false;
// Set inside the transform when a suggestion's live anchor is ambiguous
// (>=2 occurrences), so the rollback path can surface the right error.
let ambiguousInLiveDoc = false;
try {
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260). The
// /comments/create REST call above keeps the agent-supplied id.
const pageUuid = await this.resolvePageId(pageId);
// Route through the mutatePage seam (not the free function) so this
// wrapper's uniqueness gate + rollback can be unit-tested without a live
// Hocuspocus collab socket.
const mutation = await this.mutatePage(
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
@@ -2576,19 +2499,6 @@ export class DocmostClient {
liveDoc && liveDoc.type === "doc"
? liveDoc
: { type: "doc", content: [] };
if (hasSuggestion) {
// Authoritative uniqueness check against the LIVE document: a
// suggestion must anchor to EXACTLY ONE occurrence, otherwise
// "Apply" would rewrite the wrong/ambiguous text. If the live doc
// no longer has exactly one occurrence (it changed since the
// pre-check), abort so the just-created comment is rolled back
// rather than mis-anchored to the first occurrence.
const liveCount = countAnchorMatches(doc, selection as string);
if (liveCount !== 1) {
ambiguousInLiveDoc = liveCount >= 2;
return null;
}
}
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
anchored = true;
return doc;
@@ -2607,14 +2517,11 @@ export class DocmostClient {
}
if (!anchored) {
// Mutation aborted because the selection was not found (or, for a
// suggestion, was ambiguous) in the live document. Roll back the comment
// and surface a hard error.
// Mutation aborted because the selection was not found in the live
// document. Roll back the comment and surface a hard error.
await this.safeDeleteComment(newCommentId);
throw new Error(
ambiguousInLiveDoc
? "create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique."
: "create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
"create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
);
}
+64 -66
View File
@@ -105,10 +105,6 @@ 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",
{
@@ -199,10 +195,6 @@ 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",
{
@@ -230,8 +222,6 @@ 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",
{
@@ -253,8 +243,6 @@ 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",
{
@@ -457,11 +445,32 @@ server.registerTool(
},
);
// 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,
// 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 }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
@@ -469,10 +478,42 @@ registerShared(
},
);
// 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,
// 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 }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
@@ -578,10 +619,6 @@ 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",
{
@@ -709,9 +746,6 @@ 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",
{
@@ -722,10 +756,7 @@ server.registerTool(
"A top-level comment REQUIRES an exact `selection`; if the selection " +
"cannot be found in the page the call fails (no orphan comment is left). " +
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
"selection. You may also attach a `suggestedText` proposing a replacement " +
"for the `selection`; a human applies (or rejects) it from the UI. When " +
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
"page — expand it with surrounding context if it is ambiguous.",
"selection.",
inputSchema: {
pageId: z.string().describe("ID of the page to comment on"),
content: z.string().min(1).describe("Comment content in Markdown format"),
@@ -744,45 +775,20 @@ server.registerTool(
.string()
.optional()
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe(
"Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
"applied by a human via the UI (never auto-applied). REQUIRES a " +
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
"be UNIQUE in the page — expand it with surrounding context (still " +
"<=250 chars) if it occurs more than once, or the call is refused.",
),
},
},
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
async ({ pageId, content, selection, parentCommentId }) => {
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error(
"create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
);
}
if (suggestedText !== undefined) {
if (parentCommentId) {
throw new Error(
"create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
);
}
if (!selection || !selection.trim()) {
throw new Error(
"create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
);
}
}
const result = await docmostClient.createComment(
pageId,
content,
"inline",
selection,
parentCommentId,
suggestedText,
);
return jsonContent(result);
},
@@ -905,10 +911,6 @@ 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",
{
@@ -935,10 +937,6 @@ 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",
{
-127
View File
@@ -171,65 +171,6 @@ export function findAnchorInBlock(
return null;
}
/**
* Reconstruct the RAW text spanned by an AnchorMatch inside one block's
* `content` array. `startChild..endChild` are all text nodes (guaranteed by
* findAnchorInBlock, which only builds runs of `text` nodes), so concatenate
* each node's text slice: from `startOffset` on the first node, up to
* `endOffset` on the last, and the whole `.text` for any node fully inside the
* range. Mirrors spliceCommentMark's per-node slicing so the string returned
* here is EXACTLY the characters the comment mark will cover.
*/
function reconstructRawText(blockContent: any[], match: AnchorMatch): string {
const { startChild, startOffset, endChild, endOffset } = match;
let out = "";
for (let k = startChild; k <= endChild; k++) {
const n = blockContent[k];
const text: string = typeof n.text === "string" ? n.text : "";
const sliceStart = k === startChild ? startOffset : 0;
const sliceEnd = k === endChild ? endOffset : text.length;
out += text.slice(sliceStart, sliceEnd);
}
return out;
}
/**
* Return the RAW document substring that `selection` would anchor to the exact
* characters the comment mark will cover or `null` when the selection cannot
* be anchored anywhere in `doc`.
*
* This mirrors canAnchorInDoc / applyAnchorInDoc EXACTLY (same depth-first,
* document-order traversal and the same findAnchorInBlock match on the FIRST
* matching block), but instead of a boolean / an in-place mutation it
* reconstructs the raw text spanned by the matched range. Because
* findAnchorInBlock maps the normalized selection back to raw text-node
* positions, the returned string is the document's ORIGINAL characters (smart
* quotes, em-dashes, nbsp, collapsed whitespace) NOT the normalized ASCII
* agent input.
*
* Callers store THIS as the comment's `selection` so the stored value equals the
* text actually under the mark, which is what the apply-suggestion equality
* check (replaceYjsMarkedText's `joinedText !== expectedText`) compares against.
* Without it a suggestion whose anchor only matched via normalization would be
* un-appliable (spurious 409).
*/
export function getAnchoredText(doc: any, selection: string): string | null {
const visit = (node: any, depth: number): string | null => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return null;
if (!Array.isArray(node.content)) return null;
const match = findAnchorInBlock(node.content, selection);
if (match) return reconstructRawText(node.content, match);
for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
const found = visit(child, depth + 1);
if (found !== null) return found;
}
}
return null;
};
return visit(doc, 0);
}
/**
* Depth-first, document-order check for whether `selection` can be anchored
* anywhere in `doc`. At each node with an array `content`, first try to match
@@ -301,74 +242,6 @@ function spliceCommentMark(
blockContent.splice(startChild, endChild - startChild + 1, ...fragments);
}
/**
* Count how many times `selection` occurs across the whole document, using the
* same normalization and run-matching as findAnchorInBlock but WITHOUT stopping
* at the first hit: every non-overlapping occurrence within each block's text
* runs is counted and summed across all blocks (depth-first, the same traversal
* as canAnchorInDoc).
*
* This is the uniqueness gate for SUGGESTIONS: because applying a suggestion
* rewrites the exact anchored text, an ambiguous anchor (>1 occurrence) would
* silently edit the wrong place, so a suggestion is only allowed when this
* returns exactly 1. Ordinary comments keep first-occurrence anchoring and do
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
* occurrences inside one block are correctly reported as 2.)
*/
export function countAnchorMatches(doc: any, selection: string): number {
const normSel = normalizeForMatch(selection).norm.trim();
if (normSel.length === 0) return 0;
// Count non-overlapping occurrences of the normalized selection within a
// single block's direct content, matching findAnchorInBlock's run building.
const countInBlock = (blockContent: any[]): number => {
if (!Array.isArray(blockContent)) return 0;
let count = 0;
let i = 0;
while (i < blockContent.length) {
const node = blockContent[i];
if (!node || typeof node !== "object" || node.type !== "text") {
i++;
continue;
}
// Accumulate a maximal run of consecutive text nodes.
let rawRun = "";
let j = i;
while (j < blockContent.length) {
const n = blockContent[j];
if (!n || typeof n !== "object" || n.type !== "text") break;
rawRun += typeof n.text === "string" ? n.text : "";
j++;
}
const norm = normalizeForMatch(rawRun).norm;
// Count every non-overlapping occurrence in this run.
let from = 0;
for (;;) {
const idx = norm.indexOf(normSel, from);
if (idx === -1) break;
count++;
from = idx + normSel.length;
}
i = j > i ? j : i + 1;
}
return count;
};
let total = 0;
const visit = (node: any, depth: number): void => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return;
if (!Array.isArray(node.content)) return;
total += countInBlock(node.content);
for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
visit(child, depth + 1);
}
}
};
visit(doc, 0);
return total;
}
/**
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
* whose content matches `selection`, splice the comment mark across the matched
-5
View File
@@ -75,11 +75,6 @@ export function filterComment(comment: any, markdownContent?: string) {
editedAt: comment.editedAt || null,
resolvedAt: comment.resolvedAt || null,
resolvedById: comment.resolvedById || null,
// Suggestion state: the proposed replacement text (if any) and, once a human
// applies it via the UI, when and by whom.
suggestedText: comment.suggestedText || null,
suggestionAppliedAt: comment.suggestionAppliedAt || null,
suggestionAppliedById: comment.suggestionAppliedById || null,
};
}
-92
View File
@@ -119,98 +119,6 @@ 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: {
@@ -229,322 +229,3 @@ test("a reply creates without selection or anchoring and is stored as type 'page
"a reply must skip the pre-check/anchoring (no /pages/info read)",
);
});
// -----------------------------------------------------------------------------
// 4) suggestedText + a DUPLICATE selection is refused BEFORE creating anything:
// a suggestion must anchor to a unique location, so >=2 occurrences throws the
// ambiguity error (the /pages/info pre-check short-circuits before create).
// -----------------------------------------------------------------------------
test("suggestedText with an ambiguous selection is refused before creating", async () => {
let createCalls = 0;
let infoCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
infoCalls++;
// "target" appears in two blocks -> ambiguous for a suggestion.
sendJson(res, 200, {
data: {
id: "page-1",
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first target here" }] },
{ type: "paragraph", content: [{ type: "text", text: "second target here" }] },
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createCalls++;
sendJson(res, 200, { data: { id: "should-not-happen" } });
return;
}
sendJson(res, 404, { message: "not found" });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() =>
client.createComment(
"page-1",
"body",
"inline",
"target",
undefined,
"TARGET",
),
/ambiguous/i,
"an ambiguous suggestion selection must reject with the ambiguity error",
);
assert.ok(infoCalls >= 1, "the pre-check must read the page via /pages/info");
assert.equal(
createCalls,
0,
"/comments/create must NEVER be called for an ambiguous suggestion",
);
});
// -----------------------------------------------------------------------------
// 5) suggestedText on a reply is refused immediately (before any HTTP).
// -----------------------------------------------------------------------------
test("suggestedText on a reply is rejected", async () => {
let anyCall = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
anyCall++;
sendJson(res, 200, { data: { id: "x" } });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() =>
client.createComment(
"page-1",
"body",
"inline",
undefined,
"parent-1",
"replacement",
),
/reply/i,
"suggestedText on a reply must be rejected",
);
assert.equal(anyCall, 0, "no create/info call for a rejected reply suggestion");
});
// -----------------------------------------------------------------------------
// 6) suggestedText without a selection is refused immediately.
// -----------------------------------------------------------------------------
test("suggestedText without a selection is rejected", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
sendJson(res, 200, { data: { id: "x" } });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() =>
client.createComment(
"page-1",
"body",
"inline",
undefined,
undefined,
"replacement",
),
/selection/i,
"suggestedText without a selection must be rejected",
);
});
// -----------------------------------------------------------------------------
// 7) suggestedText + a UNIQUE selection succeeds: the pre-check passes, the
// create payload carries suggestedText, and the live anchoring step (stubbed
// via the mutatePage seam) writes the comment mark exactly once.
// -----------------------------------------------------------------------------
test("suggestedText with a unique selection succeeds and forwards the payload", async () => {
let createPayload = null;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
// "brave" is unique in the page.
sendJson(res, 200, {
data: {
id: "11111111-1111-1111-1111-111111111111",
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createPayload = JSON.parse(raw);
sendJson(res, 200, {
data: {
id: "cmt-ok-1",
content: createPayload.content,
selection: createPayload.selection,
suggestedText: createPayload.suggestedText,
type: createPayload.type,
},
});
return;
}
sendJson(res, 404, { message: "not found" });
});
// Subclass to stub the collab write seam: no live Hocuspocus socket, but the
// wrapper's uniqueness gate + applyAnchorInDoc still run against `doc`.
class TestClient extends DocmostClient {
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId(pageId) {
return "11111111-1111-1111-1111-111111111111";
}
async mutatePage(pageId, collabToken, apiUrl, transform) {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
],
};
const out = transform(doc);
return { doc: out, verify: { ok: true } };
}
}
const client = new TestClient(baseURL, "user@example.com", "pw");
const result = await client.createComment(
"11111111-1111-1111-1111-111111111111",
"please rename",
"inline",
"brave",
undefined,
"bold",
);
assert.equal(result.success, true, "a unique suggestion must resolve");
assert.equal(result.anchored, true, "the comment must be anchored");
assert.ok(createPayload, "/comments/create must have been called");
assert.equal(
createPayload.suggestedText,
"bold",
"the create payload must carry suggestedText for a top-level inline comment",
);
assert.equal(createPayload.selection, "brave");
assert.equal(result.data.suggestedText, "bold", "filterComment surfaces suggestedText");
});
// -----------------------------------------------------------------------------
// 8) suggestedText where the DOC has TYPOGRAPHIC text and the agent selection is
// ASCII: the stored selection sent to /comments/create MUST be the doc's RAW
// typographic substring (what the mark covers), NOT the agent's ASCII input.
// This is the F1 contract that makes "Apply" succeed instead of a spurious
// 409 (apply compares the stored selection to the marked doc text strictly).
// -----------------------------------------------------------------------------
test("suggestedText: the stored selection is the doc's RAW typographic substring, not the ASCII input", async () => {
let createPayload = null;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
// The doc holds SMART quotes; the agent will select the ASCII form.
sendJson(res, 200, {
data: {
id: "22222222-2222-2222-2222-222222222222",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "he said “hello” loudly" }],
},
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createPayload = JSON.parse(raw);
sendJson(res, 200, {
data: {
id: "cmt-typo-1",
content: createPayload.content,
selection: createPayload.selection,
suggestedText: createPayload.suggestedText,
type: createPayload.type,
},
});
return;
}
sendJson(res, 404, { message: "not found" });
});
class TestClient extends DocmostClient {
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId() {
return "22222222-2222-2222-2222-222222222222";
}
async mutatePage(pageId, collabToken, apiUrl, transform) {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "he said “hello” loudly" }],
},
],
};
const out = transform(doc);
return { doc: out, verify: { ok: true } };
}
}
const client = new TestClient(baseURL, "user@example.com", "pw");
const result = await client.createComment(
"22222222-2222-2222-2222-222222222222",
"please change",
"inline",
'"hello"', // ASCII quotes — the doc has smart quotes
undefined,
"goodbye",
);
assert.equal(result.success, true);
assert.equal(result.anchored, true);
assert.ok(createPayload, "/comments/create must have been called");
assert.equal(
createPayload.selection,
"“hello”",
"the stored selection must be the doc's RAW typographic substring, not the ASCII input",
);
assert.equal(createPayload.suggestedText, "goodbye");
});
@@ -6,8 +6,6 @@ import {
findAnchorInBlock,
canAnchorInDoc,
applyAnchorInDoc,
countAnchorMatches,
getAnchoredText,
} from "../../build/lib/comment-anchor.js";
const COMMENT_ID = "cmt-123";
@@ -210,101 +208,3 @@ test("anchoring works inside a nested block (e.g. list item) via DFS recursion",
assert.equal(marked.length, 1);
assert.equal(marked[0].text, "target");
});
// ---------------------------------------------------------------------------
// countAnchorMatches — the uniqueness gate for suggestions. Counts every
// non-overlapping occurrence across the whole document (0 / 1 / N).
// ---------------------------------------------------------------------------
test("countAnchorMatches returns 0 when the selection is absent", () => {
const doc = paragraphDoc([{ type: "text", text: "hello world" }]);
assert.equal(countAnchorMatches(doc, "missing"), 0);
});
test("countAnchorMatches returns 1 for a unique selection", () => {
const doc = paragraphDoc([{ type: "text", text: "Hello brave world" }]);
assert.equal(countAnchorMatches(doc, "brave"), 1);
});
test("countAnchorMatches counts multiple occurrences within one block", () => {
const doc = paragraphDoc([{ type: "text", text: "ab ab ab" }]);
assert.equal(countAnchorMatches(doc, "ab"), 3);
});
test("countAnchorMatches sums occurrences across separate blocks", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first target here" }] },
{ type: "paragraph", content: [{ type: "text", text: "second target here" }] },
],
};
assert.equal(countAnchorMatches(doc, "target"), 2);
});
test("countAnchorMatches counts a match spanning adjacent text nodes as one", () => {
const doc = paragraphDoc([
{ type: "text", text: "запуска ", marks: [{ type: "italic" }] },
{ type: "text", text: "перед блоком", marks: [{ type: "italic" }] },
]);
assert.equal(countAnchorMatches(doc, "запуска перед"), 1);
});
test("countAnchorMatches counts matches inside nested (recursed) blocks", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "outer target" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "nested target" }] },
],
},
],
},
],
};
assert.equal(countAnchorMatches(doc, "target"), 2);
});
test("countAnchorMatches applies the same normalization as anchoring", () => {
// Smart quotes in the doc match ASCII quotes in the selection.
const doc = paragraphDoc([{ type: "text", text: "say “hi” now" }]);
assert.equal(countAnchorMatches(doc, '"hi"'), 1);
});
// -----------------------------------------------------------------------------
// getAnchoredText: returns the RAW document substring the mark would cover (the
// doc's original typographic characters), not the normalized ASCII selection.
// This is what makes a suggestion's stored selection equal the apply-time
// expectedText, so the strict equality in replaceYjsMarkedText holds.
// -----------------------------------------------------------------------------
test("getAnchoredText returns the RAW (typographic) doc substring for an ASCII selection", () => {
// Doc holds smart quotes; agent selection is the ASCII form.
const doc = paragraphDoc([{ type: "text", text: "he said “hello” loudly" }]);
assert.equal(getAnchoredText(doc, '"hello"'), "“hello”");
});
test("getAnchoredText undoes whitespace/dash normalization to the raw span", () => {
// Em-dash + nbsp in the doc; ASCII hyphen + single space in the selection.
const doc = paragraphDoc([{ type: "text", text: "a—b c" }]);
// selection "a-b c" (ascii dash) matches, raw substring keeps the em-dash+nbsp.
assert.equal(getAnchoredText(doc, "a-b c"), "a—b c");
});
test("getAnchoredText spans consecutive text nodes and returns their raw slices", () => {
const doc = paragraphDoc([
{ type: "text", text: "Hello " },
{ type: "text", text: "“brave”", marks: [{ type: "bold" }] },
{ type: "text", text: " world" },
]);
assert.equal(getAnchoredText(doc, '"brave" wor'), "“brave” wor");
});
test("getAnchoredText returns null when the selection does not anchor", () => {
const doc = paragraphDoc([{ type: "text", text: "hello world" }]);
assert.equal(getAnchoredText(doc, "not present"), null);
});
@@ -83,63 +83,6 @@ 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`);