Compare commits

..

16 Commits

Author SHA1 Message Date
claude code agent 227 48c1ec46f7 fix(comment): store the real anchored substring as expectedText + pin authz (#318 F1/F2)
F1 [blocking]: a suggestion whose anchor matched via normalization could never
be applied (spurious 409). The comment mark lands on the doc's ACTUAL text
(Docmost auto-converts to typographic quotes/dashes/nbsp), but the stored
selection — used as expectedText at apply — was the raw ASCII agent input
(+substring(0,250)). So replaceYjsMarkedText's strict joined!==expectedText
always failed and threw "text changed" though nobody edited. Fix: new pure
getAnchoredText(doc, selection) reconstructs the exact raw doc substring the mark
covers (slicing identical to spliceCommentMark); on the suggestion path
client.createComment stores THAT as selection, so expectedText equals the marked
text and apply returns applied:true. Live anchoring still uses the raw agent
selection (normalization still finds the anchor). Truncation raised 250->2000
(+ DTO @MaxLength(2000)) so the anchored substring is never cut below the mark
span. Ordinary comments unchanged. AI-chat shares client.createComment, so
covered. Regression tests: getAnchoredText raw-vs-ASCII; create payload selection
is the typographic substring; apply with typographic expectedText -> applied.

F2 [blocking]: added comment.controller.spec.ts pinning that validateCanEdit runs
before applySuggestion (Forbidden -> applySuggestion never called; happy path ->
called; missing comment -> 404 without authorizing).

MCP 448 pass; server comment+yjs 54 pass. MCP build/ rebuilt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:29:42 +03:00
claude code agent 227 cd539558ed feat(agent-tools): suggestedText on create_comment with strict anchor uniqueness (#315 phase 6)
Agents can attach a suggested replacement when creating an inline comment, via
both the MCP create_comment tool and the AI-chat createComment tool.

Because applying a suggestion edits the EXACT anchored text, an ambiguous anchor
would let Apply corrupt the wrong occurrence. So when suggestedText is set the
selection must occur EXACTLY ONCE:
- new countAnchorMatches(doc, selection) counts occurrences across all blocks
  (same normalization/traversal as canAnchorInDoc), counting occurrences (2 in
  one block => 2) — stricter than block-count, never under-counting distinct
  occurrences (false-unique is the dangerous direction).
- client.createComment gains suggestedText: a pre-check (getPageJson +
  countAnchorMatches: 0 => not-found, >=2 => ambiguity error) before create, and
  an AUTHORITATIVE live check inside the anchoring mutation that recomputes on the
  live doc and, if != 1, aborts and rolls back the just-created comment (reusing
  the existing safeDeleteComment "anchor not found" path). Ordinary comments keep
  first-occurrence behavior unchanged.
- suggestedText is rejected on a reply or without selection in all three layers
  (MCP handler, MCP client, AI-chat tool), mirroring the server DTO/service.
- filterComment surfaces suggestedText/suggestionAppliedAt/suggestionAppliedById.
- DocmostClientLike.createComment signature updated. MCP build/ rebuilt.

Tests: countAnchorMatches (0/1/N, within/across/nested block, span nodes,
quote normalization); createComment (ambiguous refused pre-create, reply and
no-selection rejected, unique succeeds and forwards suggestedText, filterComment
surfaces it); ai-chat schema accepts suggestedText. MCP 443 pass; ai-chat 601 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:35:47 +03:00
claude code agent 227 b62db917de feat(comment): suggestion diff block + Apply button + mutation (#315 phase 5)
Client UI for agent comment suggestions.

- IComment gains suggestedText / suggestionAppliedAt / suggestionAppliedById.
- comment-list-item shows a "было → стало" block (old selection struck/red, new
  suggestedText green) for a top-level comment with a suggestion, plus an Apply
  button — gated by canShowApply(comment, canEdit): edit permission AND a
  suggestion AND not applied AND not resolved AND top-level. Once applied, an
  "Applied" badge replaces the button.
- canEdit comes from page.permissions.canEdit (real edit permission, NOT the
  looser canComment) and is threaded through CommentListItem and nested
  ChildComments; fail-closed when undefined.
- useApplySuggestionMutation posts to /comments/apply-suggestion; on success it
  writes the applied + server auto-resolve fields into the react-query cache
  (UI flips to Applied + resolved without a refetch); on 409 it shows a specific
  message with the server's currentText, else a generic error.
- i18n keys added in en-US + ru-RU.

Tests (comment-list-item.test.tsx + canShowApply unit suite): Apply visibility
across canEdit/applied/resolved/reply, click dispatches the mutation, diff
rendering. 34 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:19:36 +03:00
claude code agent 227 ec542a924b feat(comment): store suggestedText + POST /comments/apply-suggestion (#315 phase 4)
Server side of agent comment suggestions.

- CreateCommentDto gains optional suggestedText (<=2000). CommentService.create
  accepts it ONLY for a top-level inline comment with a non-empty selection,
  requires it be non-empty and differ from selection (else BadRequest), and
  stores it.
- POST /comments/apply-suggestion (ApplySuggestionDto { commentId }): authorizes
  with validateCanEdit (applying edits page text) BEFORE any structural check or
  mutation, then CommentService.applySuggestion:
  - runs the phase-3 collab event applyCommentSuggestion on `page.<pageId>` to
    atomically check-and-replace the marked text, returning { applied, currentText };
  - applied → stamp suggestion_applied_at/by, auto-resolve the thread, ws
    commentUpdated, audit COMMENT_SUGGESTION_APPLIED;
  - already-applied (DB) → idempotent success (no re-apply), self-healing the
    resolve if it was missed — satisfies the issue's double-click / two-user
    race requirement;
  - collab verdict applied:false && currentText===suggestedText → idempotent
    success (crash between doc mutation and DB write);
  - text changed → 409 ConflictException carrying currentText;
  - gateway undefined/throw → hard error, never a silent success.
- audit-events: COMMENT_SUGGESTION_APPLIED.

Tests: create validation (reply/no-selection/equal-to-selection rejected;
valid stored) + applySuggestion verdict branches incl. both idempotent paths.
jest src/core/comment: 33 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:09:23 +03:00
claude code agent 227 a9da8f7f15 feat(collab): applyCommentSuggestion event + no-Redis local fallback (#315 phase 3)
New custom collab event applyCommentSuggestion runs replaceYjsMarkedText inside
the document's Yjs transaction on the owning instance and returns the
{ applied, currentText } verdict to the API-server caller (cross-process via the
Redis bridge, whose customEventComplete/replyId already carries handler return
values).

- withYdocConnection is now generic and returns the callback's result (captured
  in a closure, since hocuspocus connection.transact does not forward it). The
  callback is typed synchronous-only: transact runs fn synchronously without
  awaiting, so an async fn would mutate outside the transaction and lose
  atomicity.
- collaboration.gateway.handleYjsEvent: when Redis is disabled
  (COLLAB_DISABLE_REDIS), dispatch the handler locally against the single
  hocuspocus instance and return its verdict instead of silently returning
  undefined (which would make apply a no-op). Also fixes the pre-existing silent
  no-op of setCommentMark/resolveCommentMark without Redis.

Tests: handler spec (applied mutates doc + returns verdict; changed-text returns
{applied:false} without mutating; args forwarded; withYdocConnection returns the
value) and gateway spec (no-Redis path dispatches locally, returns the verdict,
not undefined).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:52:44 +03:00
claude code agent 227 7c0664d2b3 feat(collab): replaceYjsMarkedText — atomic check-and-replace of comment-marked text (#315 phase 2)
The primitive behind "Apply comment suggestion": walk the XmlFragment, collect
the delta segments carrying the `comment` mark for a commentId, and replace them
with new text ONLY if the run is intact (single Y.XmlText, contiguous, and the
joined text still equals the expected anchor). Otherwise return a verdict
{ applied:false, currentText } — null when the anchor is gone, else the current
text — so the caller can report "someone changed it". On apply it deletes the
run and re-inserts the new text re-attaching the same comment mark (thread stays
anchored). Mutates in place for the caller's connection.transact(); opens no
transaction of its own.

Non-string inserts (embeds) advance the offset by their 1-unit index length so a
marked segment after an embed gets the right position and an embed inside a run
is correctly rejected as a changed anchor.

Tests (yjs.util.spec.ts): happy path (mark preserved, surrounding text and no
mark-bleed), resolved-mark match, changed text, deleted anchor, paragraph split,
interleaved unmarked text, and embed before/inside the run. 17 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:41:32 +03:00
claude code agent 227 a32fba63ec feat(comment): db columns for comment suggestions (#315 phase 1)
Add suggested_text / suggestion_applied_at / suggestion_applied_by_id to the
comments table (migration) and mirror them in the hand-curated db.d.ts Comments
interface. suggested_text holds a proposed replacement for the comment's
anchored selection; the applied_* columns record who applied it and when.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:29:03 +03:00
vvzvlad 36b3539571 Merge pull request 'refactor(ai-chat): move patch_node/insert_node into the shared tool-spec registry (#294)' (#305) from refactor/294-tool-spec-registry into develop
Reviewed-on: #305
2026-07-03 18:02:40 +03:00
vvzvlad a63efa6920 Merge pull request 'fix(ai-chat): stop the reasoning-stream hang — parse markdown only when expanded (#302)' (#303) from fix/302-reasoning-parse-when-open into develop
Reviewed-on: #303
2026-07-03 18:02:16 +03:00
vvzvlad 88d96c41b5 Merge pull request 'feat(ai-chat): agent avatar stack — agent front, launcher behind (#300)' (#304) from feat/300-agent-avatar-stack into develop
Reviewed-on: #304
2026-07-03 16:01:17 +03:00
claude_code ef16743406 fix(#300 ui): flip avatar hierarchy on comments, make launcher visible
Two visual defects in the agent avatar stack (PR #304), missed by the
code-only review:

- The launcher (human) avatar was fully occluded behind the opaque agent
  glyph — the container was exactly the glyph size, so the launcher sat
  underneath it. Enlarge the container by an overhang and vertically center
  the glyph so the launcher peeks out at the bottom-right and stays visible.
- On comments the human creator stayed the PRIMARY avatar and name while the
  stack was crammed into the old badge slot, duplicating the identity and
  failing the "agent is primary" requirement. AgentAvatarStack gains a
  showName prop; with showName=false it now replaces the leading avatar for
  agent comments, and the name slot renders agent.name (+ dimmed
  · launcher.name). Non-agent comments are byte-identical to before;
  history-item keeps the default (names shown).

Tests: add showName=false and external-MCP (no-launcher) coverage, assert
no identity duplication. client tsc clean, 9 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:08:16 +03:00
vvzvlad 6c208a965f Merge pull request 'fix(editor): убрать рывок восстановления позиции чтения при reload — авто-фокус заголовка (#266)' (#301) from feat/scroll-restore-ux into develop
Reviewed-on: #301
2026-07-03 13:50:14 +03:00
agent_coder 86c1307ed2 fix(#300 review): drop stray symlink, re-fetch enriched on comment update, cover history mapping (F1/F2/F3)
F1: remove an accidentally-committed self-referential symlink
packages/mcp/node_modules/node_modules -> an absolute build-machine path (leaked a dev
home path, a pnpm artifact useless in the repo), and add a targeted ignore so it can't
recommit.
F2: the commentUpdated broadcast re-emitted the caller's pre-loaded comment mutated in
place, so the {agent,launcher} stack survived only because the controller happened to
load it with includeCreator:true — the fragile coupling that let the stack vanish on
edit once already. update() now RE-FETCHES the enriched comment before broadcasting,
symmetric with create()/resolveComment() (the row is already persisted), so all three
broadcasts carry the stack regardless of any caller's pre-load. Adds a caller-contract
test asserting all three broadcasts emit agent/launcher for an agent comment and neither
for a non-agent one, spotlighting the update path (non-vacuous vs the old re-emit).
F3: add a direct test of the page-history attachPageHistoryAgent mapping (its distinct
lastUpdatedSource/lastUpdatedAiChatId/lastUpdatedBy column set): role / no-role / MCP /
non-agent, and that the internal agentRole join column is stripped.

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

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

Partially addresses #294.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:55:11 +03:00
agent_coder 0968ea97d2 feat(ai-chat): agent avatar stack — agent in front, launcher behind (#300)
For AI-agent-authored content (comments + page history), replace the text AI-AGENT
badge with an avatar stack: the agent in front, the human who launched it smaller and
behind. This fixes the inverted hierarchy (the action was the agent's; the human just
launched it). closes #300.

Backend: a single server-authoritative resolver resolveAgentProvenance normalizes to
{ agent, launcher } from server columns only (createdSource/lastUpdatedSource, aiChatId,
creator, chat role) — nothing from request input, so agent identity can't be spoofed.
Internal chat -> agent = chat role (name/emoji), launcher = human; external MCP
(aiChatId null) -> agent = the agent account, launcher = null; non-agent -> neither.
The role join (aiChatId -> ai_chats.role_id -> ai_agent_roles) deliberately does NOT
filter enabled/deleted_at, so a later-disabled role still labels historical content
(mirrors findById, not findLiveEnabled). Enrichment is applied on BOTH findPageComments
(list) AND findById (the create/resolve/update broadcast path), so the stack shows on
live comment events and doesn't vanish on resolve/edit.

Frontend: new AgentAvatarStack + AgentGlyph (avatarUrl -> role emoji on violet ->
IconSparkles on violet), integrated into comment-list-item and history-item where the
badge was; the deep-link-to-chat click moved onto the stack. ai-agent-badge removed.

Tests: AgentAvatarStack (role/no-role/MCP/click/non-clickable), the provenance resolver
+ recorder tests proving the role join never filters enabled/deleted, and findById
enrichment (guards the live-broadcast regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:28:53 +03:00
agent_coder 2d30ad1fa2 fix(ai-chat): parse reasoning markdown only while expanded to stop the thinking-stream hang (#302)
The reasoning block memoized its markdown render on [trimmed] alone, so as the
reasoning text streamed in it re-parsed the whole, ever-growing text (marked +
DOMPurify) on every throttled delta (~20Hz) — an O(n^2) CPU storm that pinned the
main thread and froze the chat during a long "thinking" phase. Worse, the block is
collapsed by default, so all that parsing was for a hidden body the user never sees
(html is only shown inside <Collapse in={open}>).

Gate the parse on `open`: collapsed shows the cheap raw-text fallback and does no
markdown parsing; expanding parses the current text once (an instant user click), and
further streaming while open is the normal per-delta append render, like the answer.

Test: assert renderChatMarkdown is not called while collapsed and is called once on
expand.

closes #302

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:01:37 +03:00
60 changed files with 4372 additions and 492 deletions
@@ -1222,8 +1222,8 @@
"Commented": "Commented",
"Resolved comment": "Resolved comment",
"Ran tool {{name}}": "Ran tool {{name}}",
"AI-agent": "AI-agent",
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
"AI agent {{name}}": "AI agent {{name}}",
"Endpoints": "Endpoints",
"where we fetch models": "where we fetch models",
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
@@ -1373,5 +1373,10 @@
"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)"
"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."
}
@@ -724,7 +724,8 @@
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
"AI agent {{name}}": "AI-агент {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
@@ -1228,5 +1229,10 @@
"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)": "Подключение… (только чтение)"
"Connecting… (read-only)": "Подключение… (только чтение)",
"Apply": "Применить",
"Applied": "Применено",
"Suggestion applied": "Предложение применено",
"Failed to apply suggestion": "Не удалось применить предложение",
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
}
@@ -0,0 +1,119 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AgentAvatarStack } from "./agent-avatar-stack";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
type Props = React.ComponentProps<typeof AgentAvatarStack>;
function renderStack(props: Props) {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const utils = render(
<Provider store={store}>
<MantineProvider>
<AgentAvatarStack {...props} />
</MantineProvider>
</Provider>,
);
return { store, ...utils };
}
describe("AgentAvatarStack", () => {
it("internal chat WITH role: emoji glyph in front + human launcher behind", () => {
const { container } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
// Emoji is used as the glyph (priority 2), NOT the sparkles fallback.
expect(screen.getByText("🔬")).toBeDefined();
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
// Label: bold role name + dimmed "· launcher".
expect(screen.getByText("Researcher")).toBeDefined();
expect(screen.getByText(/·/)).toBeDefined();
expect(screen.getByText("Alice")).toBeDefined();
});
it("showName=false: renders only the avatars, no inline name label", () => {
renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
showName: false,
});
// The agent glyph is still rendered...
expect(screen.getByText("🔬")).toBeDefined();
// ...but neither the agent NOR the launcher inline name label is rendered
// (they live only in the hover tooltip, which is not mounted in the initial
// DOM) — guards against suppressing only the agent name and leaking the
// launcher name.
expect(screen.queryByText("Researcher")).toBeNull();
expect(screen.queryByText("Alice")).toBeNull();
});
it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => {
const { container } = renderStack({
agent: { name: "AI agent", avatarUrl: null },
launcher: { name: "Bob", avatarUrl: null },
aiChatId: "chat-2",
});
// No avatarUrl and no emoji => sparkles glyph (priority 3).
expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull();
expect(screen.getByText("AI agent")).toBeDefined();
expect(screen.getByText("Bob")).toBeDefined();
});
it("external MCP: agent avatar in front, NO launcher behind", () => {
const { container } = renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
});
// avatarUrl provided (priority 1) => not the sparkles fallback.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
expect(screen.getByText("MCP Bot")).toBeDefined();
// No human behind => no "·" separator is rendered.
expect(screen.queryByText(/·/)).toBeNull();
// No internal chat => the stack is not an interactive deep-link button.
expect(screen.queryByRole("button")).toBeNull();
});
it("click deep-links into the chat when aiChatId is present", () => {
const { store } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
const button = screen.getByRole("button");
fireEvent.click(button);
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch
});
it("click is a no-op / not interactive without a chat target", () => {
const onActivate = vi.fn();
renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
onActivate,
});
expect(screen.queryByRole("button")).toBeNull();
expect(onActivate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,209 @@
import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// The FRONT identity (the acting agent) and the BEHIND identity (the human who
// launched it). Both are computed server-side (#300) so the client never branches
// on the internal-vs-MCP provenance — it just renders whatever it is handed.
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
// Same violet token as the former AiAgentBadge (which used color="violet").
const AGENT_COLOR = "violet";
const GLYPH_SIZE = 38;
const LAUNCHER_SIZE = 22;
// How far the launcher avatar sticks out past the agent's bottom-right corner, so
// the "human behind" reads as behind (lower z-index) yet stays clearly visible.
const LAUNCHER_OVERHANG = 8;
/**
* The front avatar. Image-source priority (#300):
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
* 2. agent.emoji -> the role emoji on a violet circle.
* 3. otherwise -> the IconSparkles glyph on a violet circle (fallback).
*/
function AgentGlyph({ agent }: { agent: AgentInfo }) {
if (agent.avatarUrl) {
return (
<CustomAvatar
size={GLYPH_SIZE}
avatarUrl={agent.avatarUrl}
name={agent.name}
/>
);
}
if (agent.emoji) {
return (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
{agent.emoji}
</span>
</Avatar>
);
}
return (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
</Avatar>
);
}
export interface AgentAvatarStackProps {
agent: AgentInfo;
// null/absent => external MCP (front agent avatar only, no human behind).
launcher?: LauncherInfo | null;
// Deep-links into the internal AI chat when present (null for external MCP).
aiChatId?: string | null;
// Fired after the stack deep-links into its chat, so the caller can react
// (e.g. the page-history row closes the history modal). Keeps this ui/ primitive
// free of cross-feature coupling (inherited from the old AiAgentBadge, #143).
onActivate?: () => void;
// Whether to render the inline name label next to the avatars (default true).
// Set false when the caller renders the name itself (e.g. the comment row).
showName?: boolean;
}
/**
* The "agent avatar stack" (#300): the AGENT glyph in front, and — for an
* internal AI chat — the HUMAN who launched it as a smaller avatar offset behind.
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
* whole stack is a deep-link into that chat (the click the old badge owned moved
* here); the click is contained (stopPropagation) so it does not also trigger an
* enclosing row handler.
*/
export function AgentAvatarStack({
agent,
launcher,
aiChatId,
onActivate,
showName = true,
}: AgentAvatarStackProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const clickable = !!aiChatId;
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching chats must start with a clean composer — clear any unsent draft
// so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
// Internal chat => "role on behalf of person"; external MCP => just the agent.
const tooltip = launcher
? t("AI agent «{{role}}» on behalf of {{person}}", {
role: agent.name,
person: launcher.name,
})
: t("AI agent {{name}}", { name: agent.name });
// The container is only enlarged when there is a launcher to overhang; with no
// human behind it stays tight at the agent glyph size.
const stackSize = launcher ? GLYPH_SIZE + LAUNCHER_OVERHANG : GLYPH_SIZE;
const stack = (
<Box
pos="relative"
style={{
width: stackSize,
height: stackSize,
flexShrink: 0,
// Center the (in-flow) agent glyph vertically so it lines up with its
// name label; the absolutely-positioned launcher is unaffected by flex.
display: "flex",
alignItems: "center",
cursor: clickable ? "pointer" : undefined,
}}
{...(clickable
? {
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{launcher && (
<Box pos="absolute" bottom={0} right={0} style={{ zIndex: 0 }}>
<CustomAvatar
size={LAUNCHER_SIZE}
avatarUrl={launcher.avatarUrl}
name={launcher.name}
style={{ border: "2px solid var(--mantine-color-body)" }}
/>
</Box>
)}
{/* Pin the agent glyph to the top-left at its own size; the launcher then
overhangs it by LAUNCHER_OVERHANG at the bottom-right and stays visible. */}
<Box
style={{
position: "relative",
zIndex: 1,
width: GLYPH_SIZE,
height: GLYPH_SIZE,
}}
>
<AgentGlyph agent={agent} />
</Box>
</Box>
);
return (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Tooltip label={tooltip} withArrow>
{stack}
</Tooltip>
{showName && (
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{agent.name}
</Text>
{launcher && (
<>
<Text size="xs" c="dimmed" fw={400} aria-hidden>
·
</Text>
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
{launcher.name}
</Text>
</>
)}
</Group>
)}
</Group>
);
}
export default AgentAvatarStack;
@@ -1,96 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AiAgentBadge } from "./ai-agent-badge";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
return render(
<MantineProvider>
<AiAgentBadge {...props} />
</MantineProvider>,
);
}
// Render a clickable badge inside an explicit jotai store, with a leftover draft
// and an onActivate + parent-click spy, so the deep-link side effects are
// assertable. Returns the store and spies.
function setupClickable() {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const onActivate = vi.fn();
const onParentClick = vi.fn();
render(
<Provider store={store}>
<MantineProvider>
<div onClick={onParentClick}>
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
</div>
</MantineProvider>
</Provider>,
);
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
}
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
}
describe("AiAgentBadge", () => {
it("renders the AI-agent label", () => {
renderBadge({ authorName: "Bot" });
expect(screen.getByText("AI-agent")).toBeDefined();
});
it("is clickable (accessible button) when aiChatId is present", () => {
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
const badge = screen.getByRole("button");
expect(badge).toBeDefined();
expect(badge.textContent).toContain("AI-agent");
});
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
const { store, onActivate, onParentClick, badge } = setupClickable();
fireEvent.click(badge);
expectDeepLinked(store, onActivate);
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
});
it.each(["Enter", " "])(
"keyboard %j activates the deep-link (same side effects as click)",
(key) => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key });
expectDeepLinked(store, onActivate);
},
);
it("an unrelated key does NOT activate the badge", () => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key: "Tab" });
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
expect(onActivate).not.toHaveBeenCalled();
});
it.each([{ aiChatId: null }, {}])(
"is a plain non-clickable label without a chat target (%o)",
(props) => {
renderBadge({ authorName: "Bot", ...props });
expect(screen.getByText("AI-agent")).toBeDefined();
// No interactive role is exposed when there is no chat to deep-link into.
expect(screen.queryByRole("button")).toBeNull();
},
);
});
@@ -1,99 +0,0 @@
import { Badge, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
interface AiAgentBadgeProps {
authorName?: string;
aiChatId?: string | null;
// Fired after the badge deep-links into its chat. The caller handles its own
// context (e.g. the page-history row closes the history modal) so this generic
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
onActivate?: () => void;
}
/**
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
* page-history list and the comments sidebar.
*
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
* badge deep-links into that chat: it sets the active-chat atom and opens the
* floating AI-chat window, then invokes `onActivate` so the caller can react
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
* external MCP write with no internal ai_chats row), the badge is a plain
* non-clickable label. The click is contained (stopPropagation) so it does not
* also trigger an enclosing row's click handler.
*/
export function AiAgentBadge({
authorName,
aiChatId,
onActivate,
}: AiAgentBadgeProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
name: authorName ?? "",
});
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching to another chat must start with a clean composer — clear any
// unsent draft so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
const badge = (
<Badge
size="sm"
variant="light"
color="violet"
radius="sm"
leftSection={<IconSparkles size={12} stroke={2} />}
style={aiChatId ? { cursor: "pointer" } : undefined}
{...(aiChatId
? {
// Keep the default Badge root element (not a <button>) to avoid an
// invalid <button>-in-<button> nesting inside a row's
// UnstyledButton; expose it as an accessible button via
// role/keyboard.
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{t("AI-agent")}
</Badge>
);
return (
<Tooltip label={tooltip} withArrow>
{badge}
</Tooltip>
);
}
export default AiAgentBadge;
@@ -1,7 +1,14 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } 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
@@ -17,6 +24,7 @@ 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.
@@ -62,4 +70,18 @@ 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,15 +34,19 @@ 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();
// 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.
// 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.
const html = useMemo(
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
[trimmed],
() =>
open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : "",
[open, trimmed],
);
return (
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { IComment } from "@/features/comment/types/comment.types";
@@ -7,10 +7,15 @@ 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.
@@ -19,6 +24,7 @@ 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 =>
({
@@ -32,28 +38,147 @@ const baseComment = (over?: Partial<IComment>): IComment =>
...over,
}) as IComment;
function renderItem(comment: IComment) {
function renderItem(comment: IComment, canEdit = true) {
return render(
<MantineProvider>
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
<CommentListItem
comment={comment}
pageId="page-1"
canComment={true}
canEdit={canEdit}
/>
</MantineProvider>,
);
}
describe("CommentListItem — AI badge", () => {
it('renders the AI-agent badge when createdSource === "agent"', () => {
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
expect(screen.getByText("AI-agent")).toBeDefined();
describe("CommentListItem — agent avatar stack", () => {
it('flips the hierarchy for an agent comment: agent primary, launcher shown once', () => {
// Internal-chat shape with DISTINCT names so absence-of-duplication is
// assertable: creator is the human "Alice", the acting agent is "Researcher".
renderItem(
baseComment({
creator: { id: "user-1", name: "Alice", avatarUrl: null } as any,
createdSource: "agent",
aiChatId: "chat-1",
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
}),
);
// The AGENT is the primary label (the flipped hierarchy).
expect(screen.getByText("Researcher")).toBeDefined();
// The human launcher name shows exactly once — it is no longer duplicated as
// a separate creator name (that duplication is the bug this fixes).
expect(screen.getAllByText("Alice").length).toBe(1);
});
it('external MCP agent comment (no launcher): shows the agent name, no separator', () => {
// aiChatId null => external MCP: the agent IS the account, no human behind.
renderItem(
baseComment({
creator: { id: "bot-1", name: "MCP Bot", avatarUrl: null } as any,
createdSource: "agent",
aiChatId: null,
agent: { name: "MCP Bot", avatarUrl: null },
launcher: null,
}),
);
expect(screen.getByText("MCP Bot")).toBeDefined();
// No launcher => no dimmed "·" separator in the header.
expect(screen.queryByText("·")).toBeNull();
});
it('does NOT render the stack for a normal user comment (createdSource "user")', () => {
const { container } = renderItem(baseComment({ createdSource: "user" }));
// No agent glyph (sparkles) is present for a plain human comment.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
expect(screen.getByText("Service Bot")).toBeDefined();
});
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
renderItem(baseComment({ createdSource: "user" }));
expect(screen.queryByText("AI-agent")).toBeNull();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
// the insertion gate (agent → badge, user → no badge) above (#143 review).
// The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
// only guards the insertion gate (agent → stack, user → no stack).
});
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,5 +1,5 @@
import { Group, Text, Box } from "@mantine/core";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import { Group, Text, Box, Badge, Button } 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";
import { useAtom, useAtomValue } from "jotai";
@@ -11,11 +11,13 @@ 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";
@@ -24,6 +26,10 @@ 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;
}
@@ -31,6 +37,7 @@ function CommentListItem({
comment,
pageId,
canComment,
canEdit,
userSpaceRole,
}: CommentListItemProps) {
const { t } = useTranslation();
@@ -43,6 +50,7 @@ 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);
@@ -95,6 +103,18 @@ 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}"]`,
@@ -119,24 +139,44 @@ function CommentListItem({
return (
<Box ref={ref} pb={6}>
<Group gap="xs">
<CustomAvatar
size="sm"
avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
/>
{comment.createdSource === "agent" && comment.agent ? (
<AgentAvatarStack
agent={comment.agent}
launcher={comment.launcher}
aiChatId={comment.aiChatId}
showName={false}
/>
) : (
<CustomAvatar
size="sm"
avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
/>
)}
<div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name}
</Text>
{comment.createdSource === "agent" && (
<AiAgentBadge
authorName={comment.creator?.name}
aiChatId={comment.aiChatId}
/>
{comment.createdSource === "agent" && comment.agent ? (
<>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{comment.agent.name}
</Text>
{comment.launcher && (
<>
<Text size="xs" c="dimmed" fw={400} aria-hidden>
·
</Text>
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
{comment.launcher.name}
</Text>
</>
)}
</>
) : (
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name}
</Text>
)}
</Group>
@@ -191,6 +231,47 @@ 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,8 +49,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const [isLoading, setIsLoading] = useState(false);
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const canComment =
(page?.permissions?.canEdit ?? false) ||
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
// Separate active and resolved comments
@@ -137,6 +139,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
comment={comment}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
@@ -144,6 +147,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
/>
</div>
@@ -160,7 +164,14 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
[
comments,
handleAddReply,
isLoading,
space?.membership?.role,
canComment,
canEdit,
],
);
if (isCommentsLoading) {
@@ -300,6 +311,7 @@ interface ChildCommentsProps {
parentId: string;
pageId: string;
canComment: boolean;
canEdit?: boolean;
userSpaceRole?: string;
}
const ChildComments = ({
@@ -307,6 +319,7 @@ const ChildComments = ({
parentId,
pageId,
canComment,
canEdit,
userSpaceRole,
}: ChildCommentsProps) => {
const getChildComments = useCallback(
@@ -325,6 +338,7 @@ const ChildComments = ({
comment={childComment}
pageId={pageId}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
<MemoizedChildComments
@@ -332,6 +346,7 @@ const ChildComments = ({
parentId={childComment.id}
pageId={pageId}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
</div>
@@ -21,6 +21,38 @@
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,6 +5,7 @@ import {
InfiniteData,
} from "@tanstack/react-query";
import {
applySuggestion,
createComment,
deleteComment,
getPageComments,
@@ -176,6 +177,63 @@ 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,6 +18,13 @@ 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> {
@@ -1,5 +1,9 @@
import { IUser } from "@/features/user/types/user.types";
import { QueryParams } from "@/lib/types.ts";
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
export interface IComment {
id: string;
@@ -24,6 +28,18 @@ 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).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
yjsSelection?: {
anchor: any;
head: any;
@@ -0,0 +1,14 @@
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,
);
}
@@ -1,6 +1,6 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
@@ -99,12 +99,13 @@ const HistoryItem = memo(function HistoryItem({
</>
)}
{isAgentEdit && (
<AiAgentBadge
authorName={historyItem.lastUpdatedBy?.name}
{isAgentEdit && historyItem.agent && (
<AgentAvatarStack
agent={historyItem.agent}
launcher={historyItem.launcher}
aiChatId={historyItem.lastUpdatedAiChatId}
// The history row owns the modal: close it when the badge deep-links
// into the chat (the badge no longer reaches into page-history).
// The history row owns the modal: close it when the stack deep-links
// into the chat (the stack no longer reaches into page-history).
onActivate={() => setHistoryModalOpen(false)}
/>
)}
@@ -1,3 +1,8 @@
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
interface IPageHistoryUser {
id: string;
name: string;
@@ -24,4 +29,9 @@ export interface IPageHistory {
// (when present) deep-links to the chat that produced the edit.
lastUpdatedSource?: string;
lastUpdatedAiChatId?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// lastUpdatedSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
}
@@ -0,0 +1,60 @@
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,8 +147,41 @@ export class CollaborationGateway {
eventName: TName,
documentName: string,
payload: Parameters<CollabEventHandlers[TName]>[1],
) {
return this.redisSync?.handleEvent(eventName, documentName, payload);
): 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`,
);
}
openDirectConnection(documentName: string, context?: any) {
@@ -0,0 +1,132 @@
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,7 +5,12 @@ import {
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import {
replaceYjsMarkedText,
setYjsMark,
updateYjsMarkAttribute,
YjsSelection,
} from './yjs.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
@@ -73,6 +78,35 @@ 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: {
@@ -115,18 +149,28 @@ export class CollaborationHandler {
};
}
async withYdocConnection(
async withYdocConnection<T>(
hocuspocus: Hocuspocus,
documentName: string,
context: any = {},
fn: (doc: Document) => void,
): Promise<void> {
// `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> {
const connection = await hocuspocus.openDirectConnection(
documentName,
context,
);
try {
await connection.transact(fn);
// 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!;
} finally {
await connection.disconnect();
}
@@ -10,6 +10,7 @@ import {
setYjsMark,
removeYjsMarkByAttribute,
updateYjsMarkAttribute,
replaceYjsMarkedText,
type YjsSelection,
} from './yjs.util';
@@ -276,3 +277,256 @@ 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,6 +133,137 @@ 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,6 +51,7 @@ 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,6 +518,20 @@ 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,6 +173,11 @@ export class AiChatToolsService {
});
return {
// INTENTIONAL per-transport divergence (not in the shared registry): this
// in-app search runs a semantic + keyword hybrid (RRF) with in-process
// access control and a tuned schema (limit 1-20); the standalone MCP
// `search` is a plain REST full-text search (limit up to 100). Different
// behaviour AND schema, so kept per-layer.
searchPages: tool({
description:
'Search the wiki for pages relevant to a query. Combines exact ' +
@@ -432,6 +437,10 @@ export class AiChatToolsService {
},
}),
// INTENTIONAL per-transport divergence (not shared): the description is
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
// keeps its own wording. Kept per-layer.
createComment: tool({
description:
'Add an INLINE comment to a page, or reply to an existing top-level ' +
@@ -441,8 +450,10 @@ 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. Reversible via the ' +
'comment UI.',
'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.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().describe('The comment body as Markdown.'),
@@ -464,24 +475,57 @@ 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 }) => {
// 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.
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.
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 };
@@ -519,6 +563,10 @@ export class AiChatToolsService {
async () => await client.getSpaces(),
),
// INTENTIONAL per-transport divergence (not shared): keeps the `tree:true`
// hierarchy mode but is worded for the in-app agent; the standalone MCP
// `list_pages` carries its own wording. Kept per-layer so each side tunes
// its own guidance.
listPages: tool({
description:
'List the most recent pages, optionally scoped to a single space. ' +
@@ -692,85 +740,25 @@ export class AiChatToolsService {
async ({ pageId }) => await client.stashPage(pageId),
),
patchNode: tool({
description:
'Replace a single content block (by id) with a new ProseMirror ' +
'node; the replacement keeps the same nodeId. Example node: a ' +
'paragraph {"type":"paragraph","content":[{"type":"text","text":"Hello"}]} ' +
'or a heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
'may be a JSON object or a JSON string (both accepted). Reversible: ' +
'the previous version is kept in page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
nodeId: z
.string()
.describe('The block id to replace (from getOutline/getPageJson).'),
node: z
.any()
.describe(
'The replacement ProseMirror node, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.',
),
}),
execute: async ({ pageId, nodeId, node }) => {
// Parity with the standalone MCP server (index.ts patch_node): the
// model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it.
// Schema + description from the shared registry (identical across both
// transports). The execute body keeps its OWN parseNodeArg normalization:
// the model sometimes serializes the node as a JSON string, and we parse it
// before the client's typeof-object guard rejects it (parity with the
// standalone MCP server, index.ts patch_node).
patchNode: sharedTool(
sharedToolSpecs.patchNode,
async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
return await client.patchNode(pageId, nodeId, parsedNode);
},
}),
),
insertNode: tool({
description:
'Insert a ProseMirror node relative to an anchor, or append it at ' +
'the top level. For before/after you MUST provide EXACTLY ONE of ' +
'anchorNodeId or anchorText. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
'may be a JSON object or a JSON string (both accepted). Reversible ' +
'via page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
node: z
.any()
.describe(
'The ProseMirror node to insert, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.',
),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z
.string()
.optional()
.describe('Anchor block id (for before/after).'),
anchorText: z
.string()
.optional()
.describe(
'Anchor text fragment (for before/after), matched against the ' +
"block's literal rendered plain text (no markdown). " +
'Markdown/emoji are tolerated as a fallback; prefer plain text ' +
'or anchorNodeId.',
),
}),
execute: async ({
pageId,
node,
position,
anchorNodeId,
anchorText,
}) => {
// Parity with the standalone MCP server (index.ts insert_node): the
// model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it.
// Shared registry schema + description; execute retains parseNodeArg on the
// incoming node (parity with the standalone MCP server, index.ts
// insert_node).
insertNode: sharedTool(
sharedToolSpecs.insertNode,
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
return await client.insertNode(pageId, parsedNode, {
position,
@@ -778,7 +766,7 @@ export class AiChatToolsService {
anchorText,
});
},
}),
),
deleteNode: sharedTool(
sharedToolSpecs.deleteNode,
@@ -821,6 +809,10 @@ export class AiChatToolsService {
},
}),
// NOT in the shared registry: this layer names the table argument
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
// Sharing one buildShape would rename a model-facing parameter on one
// transport, so the table row/cell tools stay per-layer by design.
tableInsertRow: tool({
description:
'Insert a row of plain-text cells into a table. Reversible via ' +
@@ -841,6 +833,8 @@ export class AiChatToolsService {
await client.tableInsertRow(pageId, tableRef, cells, index),
}),
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
// divergence as tableInsertRow.
tableDeleteRow: tool({
description:
'Delete a table row at a 0-based index. Reversible via page history.',
@@ -855,6 +849,8 @@ export class AiChatToolsService {
await client.tableDeleteRow(pageId, tableRef, index),
}),
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
// divergence as tableInsertRow.
tableUpdateCell: tool({
description:
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
@@ -884,6 +880,10 @@ export class AiChatToolsService {
await client.importPageMarkdown(pageId, markdown),
),
// INTENTIONAL per-transport divergence (not shared): adds a security
// confirmation framing ("Only share when the user explicitly asked, since
// this exposes the page to anyone with the link") for the in-app agent; the
// standalone MCP `share_page` keeps the plain public-URL wording.
sharePage: tool({
description:
'Make a page PUBLICLY accessible and return its public URL. ' +
@@ -910,6 +910,10 @@ export class AiChatToolsService {
async ({ historyId }) => await client.restorePageVersion(historyId),
),
// INTENTIONAL per-transport divergence (not shared): deliberately omits the
// `deleteComments` schema field (comment-deletion guardrail) and carries a
// much shorter description; the standalone MCP `docmost_transform` exposes
// the full helper catalogue. Different schema, so kept per-layer.
transformPage: tool({
description:
'Run a sandboxed JS transform of the form `(doc, ctx) => doc` over a ' +
@@ -177,6 +177,7 @@ export interface DocmostClientLike {
type?: 'page' | 'inline',
selection?: string,
parentCommentId?: string,
suggestedText?: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
resolveComment(
commentId: string,
@@ -113,9 +113,15 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
const expectedKeys = Object.keys(shape).sort();
expect(actualKeys).toEqual(expectedKeys);
// A non-.optional() field must surface as required in the advertised schema.
// A field that was NOT wrapped in `.optional()` must surface as required in
// the advertised schema. We test for the ZodOptional wrapper rather than
// `isOptional()`: `z.any()`/`z.unknown()` accept `undefined` and so report
// `isOptional() === true`, yet z.toJSONSchema still lists them under
// `required` (they carry no `.optional()`). Matching on the wrapper is what
// the emitted JSON schema actually does, so it stays correct for the
// registry's `node: z.any()` fields (patchNode/insertNode).
const expectedRequired = Object.entries(shape)
.filter(([, field]) => !(field as z.ZodTypeAny).isOptional?.())
.filter(([, field]) => !(field instanceof z.ZodOptional))
.map(([k]) => k)
.sort();
expect((json.required ?? []).slice().sort()).toEqual(expectedRequired);
@@ -0,0 +1,119 @@
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,6 +14,7 @@ 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';
@@ -197,6 +198,42 @@ 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) {
@@ -0,0 +1,245 @@
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,6 +60,7 @@ 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,
@@ -68,14 +69,17 @@ describe('CommentService — behavior', () => {
collaborationGateway,
generalQueue,
notificationQueue,
auditService,
);
return {
service,
commentRepo,
wsService,
collaborationGateway,
generalQueue,
notificationQueue,
auditService,
};
}
@@ -181,6 +185,95 @@ 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();
@@ -0,0 +1,240 @@
import { CommentService } from './comment.service';
/**
* Caller-contract coverage for the three live comment broadcasts (#300/#304):
* - commentCreated (create @153)
* - commentUpdated (update @214) the fragile path this suite spotlights
* - commentResolved (resolveComment @283)
*
* All three must emit a payload carrying the {agent,launcher} avatar stack for an
* AGENT comment, and NEITHER field for a non-agent comment. The enrichment lives
* in CommentRepo.findById(..., {includeCreator:true}); the service contract these
* tests pin is that every broadcast reads its payload from that enriched
* single-row load rather than from an un-enriched object.
*
* NON-VACUITY for the update path: the service is handed an UN-enriched input
* comment (no agent/launcher), while findById returns the ENRICHED shape. The
* pre-#304 update() re-emitted the caller's object in place, so it would emit the
* un-enriched input and the `agent`/`launcher` assertions would FAIL. The fix
* re-fetches via findById, so the broadcast carries the stack regardless of how
* the caller pre-loaded the comment.
*/
describe('CommentService — broadcast carries the agent avatar stack', () => {
// An enriched agent comment as CommentRepo.findById(..., includeCreator:true)
// returns it: the {agent,launcher} pair is attached and agentRole is stripped.
const enrichedAgentComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'agent',
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
...over,
});
// A plain human comment: findById attaches neither agent nor launcher.
const plainHumanComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'user',
...over,
});
function makeService(findByIdReturn: unknown) {
const commentRepo: any = {
// In these flows findById is only the post-write enriched re-read
// (no parentCommentId is set, so no parent lookup path is taken).
findById: jest.fn(async () => findByIdReturn),
insertComment: jest.fn(async () => ({ id: 'comment-new' })),
updateComment: jest.fn(async () => undefined),
};
const pageRepo: any = {};
const wsService: any = { emitCommentEvent: jest.fn() };
const collaborationGateway: any = {
handleYjsEvent: jest.fn(async () => undefined),
};
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
const notificationQueue: any = { add: jest.fn(async () => undefined) };
const auditService: any = { log: jest.fn() };
const service = new CommentService(
commentRepo,
pageRepo,
wsService,
collaborationGateway,
generalQueue,
notificationQueue,
auditService,
);
return { service, commentRepo, wsService };
}
// Pull the emitted event object (3rd arg of emitCommentEvent) for an operation.
const emittedEvent = (wsService: any, operation: string) =>
wsService.emitCommentEvent.mock.calls
.map((c: any[]) => c[2])
.find((e: any) => e.operation === operation);
const page = { id: 'page-1', spaceId: 'space-1' } as any;
const user = (id = 'user-1') => ({ id }) as any;
const emptyDoc = JSON.stringify({ type: 'doc', content: [] });
describe('commentCreated', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentUpdated — the fragile path (spotlight)', () => {
it('emits agent + launcher even when the caller pre-loaded an UN-enriched comment', async () => {
// findById (the re-fetch) returns the enriched shape...
const { service, wsService, commentRepo } = makeService(
enrichedAgentComment(),
);
// ...but the caller hands in an object with NO agent/launcher. The pre-#304
// update() re-emitted THIS object in place, so this test fails against it;
// the re-fetch fix makes the broadcast independent of the pre-load.
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
// deliberately no `agent` / `launcher`
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
// The broadcast must re-read the enriched row (persisted update, then load).
expect(commentRepo.updateComment).toHaveBeenCalled();
expect(commentRepo.findById).toHaveBeenCalledWith('comment-new', {
includeCreator: true,
includeResolvedBy: true,
});
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentResolved', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
});
@@ -14,6 +14,7 @@ describe('CommentService', () => {
{} as any, // collaborationGateway
{} as any, // generalQueue
{} as any, // notificationQueue
{} as any, // auditService
);
});
+214 -6
View File
@@ -1,7 +1,10 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
@@ -26,6 +29,11 @@ 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 {
@@ -40,6 +48,7 @@ export class CommentService {
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
private notificationQueue: Queue,
@Inject(AUDIT_SERVICE) private auditService: IAuditService,
) {}
async findById(commentId: string) {
@@ -78,15 +87,58 @@ 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: createCommentDto?.selection?.substring(0, 250) ?? null,
selection,
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'),
@@ -207,17 +259,27 @@ export class CommentService {
false,
);
comment.content = commentContent;
comment.editedAt = editedAt;
comment.updatedAt = editedAt;
// Re-fetch the enriched comment before broadcasting, symmetric with
// create()/resolveComment(). updateComment() above has already persisted the
// new content/timestamps, so this single-row read reflects the edit AND
// carries the same {agent,launcher} avatar stack (via includeCreator) as the
// other two broadcasts. This deliberately does NOT reuse the caller's
// pre-loaded `comment`: relying on the controller happening to load it with
// includeCreator:true is exactly the fragile coupling that let the agent
// stack silently vanish on edit once already (#300/#304) — a future caller
// dropping that flag must not regress the broadcast.
const updatedComment = await this.commentRepo.findById(comment.id, {
includeCreator: true,
includeResolvedBy: true,
});
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment,
comment: updatedComment,
});
return comment;
return updatedComment;
}
async resolveComment(
@@ -289,6 +351,152 @@ 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[],
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class ApplySuggestionDto {
@IsUUID()
commentId: string;
}
@@ -1,4 +1,12 @@
import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
import {
IsIn,
IsJSON,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { z } from 'zod';
const yjsIdSchema = z.object({
@@ -25,8 +33,15 @@ 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()
@@ -43,4 +58,12 @@ 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;
}
@@ -0,0 +1,26 @@
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();
}
@@ -0,0 +1,129 @@
import { resolveAgentProvenance } from './agent-provenance';
import { commentAgentRoleQuery } from './comment/comment.repo';
import { pageHistoryAgentRoleQuery } from './page/page-history.repo';
/**
* The server-authoritative "agent avatar stack" resolver (#300) normalizes the
* two provenance shapes into { agent (front), launcher (behind) } so the client
* never branches. These tests pin the exact resolved shape for the three agent
* cases plus the non-agent pass-through.
*/
describe('resolveAgentProvenance', () => {
const human = { name: 'Alice', avatarUrl: 'a.png' };
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: 'chat-1',
creator: human,
agentRole: { name: 'Researcher', emoji: '🔬' },
});
expect(result).toEqual({
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
});
});
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: 'chat-1',
creator: human,
agentRole: null,
});
expect(result).toEqual({
agent: { name: 'AI agent', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
});
// The fallback agent carries no emoji (only sparkles glyph on the client).
expect(result?.agent).not.toHaveProperty('emoji');
});
it('external MCP (aiChatId null): agent = the account itself, launcher = null', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: null,
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
expect(result).toEqual({
agent: { name: 'MCP Bot', avatarUrl: 'bot.png' },
launcher: null,
});
});
it('non-agent content: returns null so the caller omits both fields', () => {
expect(
resolveAgentProvenance({
isAgent: false,
aiChatId: null,
creator: human,
agentRole: null,
}),
).toBeNull();
});
});
/**
* The role-resolution subquery must NOT filter on enabled/deletedAt: historical
* agent content keeps its signature even after the role is disabled or
* soft-deleted (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). We
* record the query-builder calls and assert the join binds only id<->roleId and
* that `where` is never called with an enabled/deletedAt filter.
*/
describe('agent role subquery — no live/enabled filter', () => {
function makeRecorder() {
const calls: { method: string; args: unknown[] }[] = [];
const builder = new Proxy(
{},
{
get(_t, prop: string) {
return (...args: unknown[]) => {
calls.push({ method: prop, args });
return builder;
};
},
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eb = { selectFrom: (...args: unknown[]) => (calls.push({ method: 'selectFrom', args }), builder) } as any;
return { eb, calls };
}
function assertNoLiveFilter(
query: (eb: any) => unknown, // eslint-disable-line @typescript-eslint/no-explicit-any
chatIdColumn: string,
) {
const { eb, calls } = makeRecorder();
query(eb);
const innerJoin = calls.find((c) => c.method === 'innerJoin');
expect(innerJoin?.args).toEqual([
'aiAgentRoles',
'aiAgentRoles.id',
'aiChats.roleId',
]);
const whereRef = calls.find((c) => c.method === 'whereRef');
expect(whereRef?.args).toEqual(['aiChats.id', '=', chatIdColumn]);
// The security-narrowing filters used by findLiveEnabled must be ABSENT.
const filtered = calls
.flatMap((c) => c.args)
.filter((a) => a === 'enabled' || a === 'deletedAt');
expect(filtered).toEqual([]);
// No `where(...)` at all (only the join + whereRef).
expect(calls.some((c) => c.method === 'where')).toBe(false);
}
it('comment subquery joins by id only, keyed on comments.aiChatId', () => {
assertNoLiveFilter(commentAgentRoleQuery, 'comments.aiChatId');
});
it('page-history subquery joins by id only, keyed on lastUpdatedAiChatId', () => {
assertNoLiveFilter(
pageHistoryAgentRoleQuery,
'pageHistory.lastUpdatedAiChatId',
);
});
});
@@ -0,0 +1,93 @@
/**
* Server-authoritative "agent avatar stack" provenance (#300).
*
* Agent-authored content (comments / page-history snapshots) is displayed as a
* two-avatar stack: the AGENT in front, and the HUMAN who launched it behind.
* This module normalizes the two provenance shapes the client can encounter into
* the SAME pair of sub-objects so the client never has to branch:
*
* agent FRONT (the acting agent identity)
* launcher BEHIND (the human on whose behalf it acted; null when there is none)
*
* The discriminator is purely SERVER-SIDE data (createdSource / lastUpdatedSource
* plus aiChatId) that only the server can set none of it is read from request
* input, so an external caller cannot spoof an `agent` badge.
*/
/** Front avatar identity. `avatarUrl`/`emoji` feed the glyph source priority. */
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
/** Behind avatar identity — the human who launched the agent (internal chat). */
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
/**
* Inputs to the resolver, drawn entirely from server-side columns:
* - `isAgent` createdSource/lastUpdatedSource === 'agent'.
* - `aiChatId` internal-AI-chat discriminator: non-null => internal chat (the
* provenance token was minted for the human, so `creator` is the human and the
* agent identity comes from the chat's role); null => external MCP (the login
* IS a dedicated agent account, so `creator` is the agent, no separate human).
* - `creator` the row's human author (internal) OR agent account (MCP).
* - `agentRole` the chat's bound role (name + optional emoji), resolved WITHOUT
* any enabled/deleted filter so historical content keeps its signature even
* after the role is disabled or soft-deleted; null when the chat has no role.
*/
export interface AgentProvenanceInput {
isAgent: boolean;
aiChatId: string | null | undefined;
creator: { name: string; avatarUrl?: string | null } | null | undefined;
agentRole: { name: string; emoji?: string | null } | null | undefined;
}
export interface AgentProvenance {
agent: AgentInfo;
launcher: LauncherInfo | null;
}
/** Fallback display name for an internal agent edit whose chat has no role. */
export const AGENT_FALLBACK_NAME = 'AI agent';
/**
* Resolve the front/behind identities from server-side provenance. Returns
* `null` for non-agent content so the caller can OMIT both fields (the client
* then keeps its plain single-human avatar).
*/
export function resolveAgentProvenance(
input: AgentProvenanceInput,
): AgentProvenance | null {
if (!input.isAgent) return null;
// External MCP: no internal chat row; the login itself is the agent account.
if (input.aiChatId == null) {
return {
agent: {
name: input.creator?.name ?? AGENT_FALLBACK_NAME,
avatarUrl: input.creator?.avatarUrl ?? null,
},
launcher: null,
};
}
// Internal AI chat: the agent identity is the chat's role (or the fallback
// when the chat has no role), and the launcher is the human chat owner.
const agent: AgentInfo = input.agentRole
? {
name: input.agentRole.name,
emoji: input.agentRole.emoji ?? null,
avatarUrl: null,
}
: { name: AGENT_FALLBACK_NAME, avatarUrl: null };
const launcher: LauncherInfo | null = input.creator
? { name: input.creator.name, avatarUrl: input.creator.avatarUrl ?? null }
: null;
return { agent, launcher };
}
@@ -0,0 +1,124 @@
import { CommentRepo } from './comment.repo';
/**
* Enrichment coverage for CommentRepo.findById (#300).
*
* The {agent,launcher} avatar stack must be attached on the SINGLE-ROW read
* path, not only on findPageComments the live websocket broadcasts
* (commentCreated/commentUpdated/commentResolved) return a comment loaded via
* findById. These tests would FAIL against the previous un-enriched findById
* (which returned the raw row without calling attachCommentAgent and without
* selecting the agent-role subquery).
*
* The Kysely db is replaced by a chainable recorder so the query never touches a
* real database: it records the `.select(...)` args (to prove the agent-role
* subquery is selected on the includeCreator path) and returns a preset row from
* executeTakeFirst (to prove attachCommentAgent maps it into {agent,launcher}).
*/
describe('CommentRepo.findById — agent avatar stack enrichment', () => {
function makeRepo(row: unknown) {
const selectArgs: unknown[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builder: any = {
selectFrom: () => builder,
selectAll: () => builder,
select: (arg: unknown) => {
selectArgs.push(arg);
return builder;
},
// Kysely's $if(condition, cb) invokes cb(qb) only when the condition is
// truthy; mirror that so gating (includeCreator) is exercised faithfully.
$if: (cond: unknown, cb: (qb: unknown) => unknown) => {
if (cond) cb(builder);
return builder;
},
where: () => builder,
executeTakeFirst: async () => row,
};
const db = { selectFrom: () => builder };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const repo = new CommentRepo(db as any);
return { repo, selectArgs };
}
const enrichOpts = { includeCreator: true, includeResolvedBy: true };
it('internal agent chat WITH role: returns agent = role, launcher = creator, and strips agentRole', async () => {
const { repo, selectArgs } = makeRepo({
id: 'c-1',
createdSource: 'agent',
aiChatId: 'chat-1',
creator: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: { name: 'Researcher', emoji: '🔬' },
});
const result: any = await repo.findById('c-1', enrichOpts);
expect(result.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(result.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
// The internal join column must never leak to the client.
expect(result).not.toHaveProperty('agentRole');
// The enrichment SELECTs the agent-role subquery on the includeCreator path
// (mirrors the list-query proof; absent in the pre-fix findById).
expect(selectArgs).toContain(repo.withAgentRole);
});
it('external MCP agent (aiChatId null): agent = the account, launcher = null', async () => {
const { repo } = makeRepo({
id: 'c-2',
createdSource: 'agent',
aiChatId: null,
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
const result: any = await repo.findById('c-2', enrichOpts);
expect(result.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
expect(result.launcher).toBeNull();
expect(result).not.toHaveProperty('agentRole');
});
it('non-agent comment: neither agent nor launcher is attached', async () => {
const { repo } = makeRepo({
id: 'c-3',
createdSource: 'user',
aiChatId: null,
creator: { name: 'Bob', avatarUrl: null },
agentRole: null,
});
const result: any = await repo.findById('c-3', enrichOpts);
expect(result).not.toHaveProperty('agent');
expect(result).not.toHaveProperty('launcher');
// A plain human comment still strips the internal join column.
expect(result).not.toHaveProperty('agentRole');
});
it('missing row: returns undefined without crashing the enrichment', async () => {
const { repo } = makeRepo(undefined);
await expect(repo.findById('nope', enrichOpts)).resolves.toBeUndefined();
});
it('non-includeCreator callers keep the plain shape (no enrichment, no agent-role select)', async () => {
const { repo, selectArgs } = makeRepo({
id: 'c-4',
createdSource: 'agent',
aiChatId: 'chat-1',
});
// No opts => the enrichment (and its subquery select) must be skipped, so
// callers doing a bare lookup (parent-comment check, controller findOne)
// are unaffected by the additive fields.
const result: any = await repo.findById('c-4');
expect(result).not.toHaveProperty('agent');
expect(result).not.toHaveProperty('launcher');
expect(selectArgs).not.toContain(repo.withAgentRole);
});
});
@@ -12,6 +12,24 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { resolveAgentProvenance } from '../agent-provenance';
/**
* Role-resolution subquery for a comment's bound AI chat (#300). Joins
* comments.aiChatId -> ai_chats.role_id -> ai_agent_roles and selects the role's
* name + emoji. NO enabled/deletedAt filter: historical agent content must keep
* its signature even after the role is later disabled or soft-deleted the same
* "resolve by id, ignore live/enabled" rule as AiAgentRoleRepo.findById (NOT
* findLiveEnabled). Exported so a unit test can assert the join binds only
* id<->roleId and never filters on enabled/deletedAt.
*/
export function commentAgentRoleQuery(eb: ExpressionBuilder<DB, 'comments'>) {
return eb
.selectFrom('aiChats')
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
.whereRef('aiChats.id', '=', 'comments.aiChatId');
}
@Injectable()
export class CommentRepo {
@@ -22,13 +40,30 @@ export class CommentRepo {
commentId: string,
opts?: { includeCreator: boolean; includeResolvedBy: boolean },
): Promise<Comment> {
return await this.db
const comment = await this.db
.selectFrom('comments')
.selectAll('comments')
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
.$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
// #300: enrich the single-row read with the agent-role subquery so the
// {agent,launcher} avatar stack is attached here too — the live websocket
// broadcasts (commentCreated/Updated/Resolved) return a comment loaded via
// findById, and must carry the SAME provenance as the list query
// findPageComments. Without this a freshly created / edited / resolved
// agent comment arrives un-enriched and the client's
// `createdSource === 'agent' && agent` gate drops the stack until a full
// refetch. Gated on includeCreator (mirroring findPageComments, which
// always selects the creator): the internal-chat launcher IS the creator,
// so the resolver needs it, and every broadcast caller passes
// includeCreator: true. Non-includeCreator callers keep the plain shape.
.$if(opts?.includeCreator, (qb) => qb.select(this.withAgentRole))
.where('id', '=', commentId)
.executeTakeFirst();
// Guard a missing row (don't destructure undefined in attachCommentAgent)
// and leave non-enriched callers' shape untouched.
if (!comment || !opts?.includeCreator) return comment;
return attachCommentAgent(comment) as Comment;
}
async findPageComments(pageId: string, pagination: PaginationOptions) {
@@ -37,15 +72,18 @@ export class CommentRepo {
.selectAll('comments')
.select((eb) => this.withCreator(eb))
.select((eb) => this.withResolvedBy(eb))
.select((eb) => this.withAgentRole(eb))
.where('pageId', '=', pageId);
return executeWithCursorPagination(query, {
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return { ...result, items: result.items.map(attachCommentAgent) };
}
async updateComment(
@@ -82,6 +120,12 @@ export class CommentRepo {
).as('creator');
}
/** Select the comment's resolved chat role (name + emoji) as `agentRole`, or
* null when the comment has no internal chat / the chat has no role (#300). */
withAgentRole(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(commentAgentRoleQuery(eb)).as('agentRole');
}
withResolvedBy(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(
eb
@@ -116,3 +160,30 @@ export class CommentRepo {
return Number(result?.count) > 0;
}
}
/**
* Attach the normalized agent/launcher provenance (#300) to a comment row and
* strip the internal `agentRole` join column. Non-agent rows pass through
* unchanged (neither field added the client keeps the plain human avatar). The
* human author (`creator`) is the launcher for an internal chat, or the agent
* itself for external MCP; the resolver encodes both cases.
*/
function attachCommentAgent<
R extends {
createdSource?: string | null;
aiChatId?: string | null;
creator?: { name: string; avatarUrl?: string | null } | null;
agentRole?: { name: string; emoji?: string | null } | null;
},
>(row: R) {
const { agentRole, ...rest } = row;
const provenance = resolveAgentProvenance({
isAgent: row.createdSource === 'agent',
aiChatId: row.aiChatId,
creator: row.creator,
agentRole,
});
return provenance
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
: rest;
}
@@ -0,0 +1,107 @@
import { PageHistoryRepo } from './page-history.repo';
/**
* Enrichment coverage for the page-history agent avatar stack (#300/#304).
*
* attachPageHistoryAgent maps a DIFFERENT column set than comments
* `lastUpdatedSource` / `lastUpdatedAiChatId` / `lastUpdatedBy` instead of
* `createdSource` / `aiChatId` / `creator` so it needs its own direct proof
* that the {agent,launcher} pair resolves for each provenance shape and that the
* internal `agentRole` join column is stripped.
*
* The mapping is exercised through findPageHistoryByPageId (the only page-history
* path that enriches). The Kysely db is a chainable recorder: query-builder
* methods return the builder and `.execute()` (called by
* executeWithCursorPagination) yields preset rows, so no real database is
* touched. The `.select((eb) => ...)` callbacks are recorded but never invoked,
* so the preset row stands in for what the DB would have returned.
*
* NON-VACUITY: against an identity mapping (raw row pass-through) the agent-case
* assertions fail `agent`/`launcher` would be undefined and the internal
* `agentRole` column would leak.
*/
describe('PageHistoryRepo.findPageHistoryByPageId — agent avatar stack enrichment', () => {
function makeRepo(rows: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builder: any = {
selectFrom: () => builder,
select: () => builder,
where: () => builder,
orderBy: () => builder,
limit: () => builder,
execute: async () => rows,
};
const db = { selectFrom: () => builder };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new PageHistoryRepo(db as any);
}
// perPage high enough that a single preset row never triggers the extra-row
// "has next page" branch (which would call generateCursor).
const pagination = { limit: 50 } as any;
const firstItem = async (row: Record<string, unknown>) => {
const repo = makeRepo([row]);
const result = await repo.findPageHistoryByPageId('page-1', pagination);
return result.items[0] as any;
};
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human, agentRole stripped', async () => {
const item = await firstItem({
id: 'ph-1',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: 'chat-1',
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: { name: 'Editor', emoji: '✏️' },
});
expect(item.agent).toEqual({ name: 'Editor', emoji: '✏️', avatarUrl: null });
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
// The internal join column must never leak to the client.
expect(item).not.toHaveProperty('agentRole');
});
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', async () => {
const item = await firstItem({
id: 'ph-2',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: 'chat-1',
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: null,
});
expect(item.agent).toEqual({ name: 'AI agent', avatarUrl: null });
expect(item.agent).not.toHaveProperty('emoji');
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
expect(item).not.toHaveProperty('agentRole');
});
it('external MCP (lastUpdatedAiChatId null): agent = the account itself, launcher = null', async () => {
const item = await firstItem({
id: 'ph-3',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: null,
lastUpdatedBy: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
expect(item.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
expect(item.launcher).toBeNull();
expect(item).not.toHaveProperty('agentRole');
});
it('non-agent (lastUpdatedSource !== "agent"): neither agent nor launcher, agentRole stripped', async () => {
const item = await firstItem({
id: 'ph-4',
lastUpdatedSource: 'user',
lastUpdatedAiChatId: null,
lastUpdatedBy: { name: 'Bob', avatarUrl: null },
agentRole: null,
});
expect(item).not.toHaveProperty('agent');
expect(item).not.toHaveProperty('launcher');
// A plain human row still strips the internal join column.
expect(item).not.toHaveProperty('agentRole');
});
});
@@ -12,6 +12,25 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { resolveAgentProvenance } from '../agent-provenance';
/**
* Role-resolution subquery for a page-history row's bound AI chat (#300). Joins
* pageHistory.lastUpdatedAiChatId -> ai_chats.role_id -> ai_agent_roles and
* selects the role's name + emoji. NO enabled/deletedAt filter: historical agent
* content must keep its signature even after the role is disabled or soft-deleted
* (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). Exported so a
* unit test can assert the join never filters on enabled/deletedAt.
*/
export function pageHistoryAgentRoleQuery(
eb: ExpressionBuilder<DB, 'pageHistory'>,
) {
return eb
.selectFrom('aiChats')
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
.whereRef('aiChats.id', '=', 'pageHistory.lastUpdatedAiChatId');
}
@Injectable()
export class PageHistoryRepo {
@@ -94,15 +113,18 @@ export class PageHistoryRepo {
.select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.select((eb) => this.withAgentRole(eb))
.where('pageId', '=', pageId);
return executeWithCursorPagination(query, {
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return { ...result, items: result.items.map(attachPageHistoryAgent) };
}
async findPageLastHistory(
@@ -138,6 +160,12 @@ export class PageHistoryRepo {
).as('lastUpdatedBy');
}
/** Select the row's resolved chat role (name + emoji) as `agentRole`, or null
* when there is no internal chat / the chat has no role (#300). */
withAgentRole(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonObjectFrom(pageHistoryAgentRoleQuery(eb)).as('agentRole');
}
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
@@ -151,3 +179,30 @@ export class PageHistoryRepo {
).as('contributors');
}
}
/**
* Attach the normalized agent/launcher provenance (#300) to a page-history row
* and strip the internal `agentRole` join column. The trigger is
* `lastUpdatedSource === 'agent'`, the internal-chat discriminator is
* `lastUpdatedAiChatId`, and the human is `lastUpdatedBy`. Non-agent rows pass
* through unchanged (neither field added).
*/
function attachPageHistoryAgent<
R extends {
lastUpdatedSource?: string | null;
lastUpdatedAiChatId?: string | null;
lastUpdatedBy?: { name: string; avatarUrl?: string | null } | null;
agentRole?: { name: string; emoji?: string | null } | null;
},
>(row: R) {
const { agentRole, ...rest } = row;
const provenance = resolveAgentProvenance({
isAgent: row.lastUpdatedSource === 'agent',
aiChatId: row.lastUpdatedAiChatId,
creator: row.lastUpdatedBy,
agentRole,
});
return provenance
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
: rest;
}
+3
View File
@@ -173,6 +173,9 @@ 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;
+1
View File
@@ -0,0 +1 @@
node_modules/node_modules
+86 -11
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 } from "./lib/comment-anchor.js";
import { applyAnchorInDoc, canAnchorInDoc, countAnchorMatches, getAnchoredText, } 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,9 +1912,21 @@ 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) {
async createComment(pageId, content, type = "page", selection, parentCommentId, suggestedText) {
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.
@@ -1924,6 +1936,16 @@ 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
@@ -1931,16 +1953,38 @@ export class DocmostClient {
if (!isReply && selection) {
try {
const page = await this.getPageJson(pageId);
if (!canAnchorInDoc(page.content, selection)) {
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)) {
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" error; swallow read/network errors so the
// live anchor step can still try (and enforce) the anchoring.
// Rethrow our own "not found"/"ambiguous" errors; swallow read/network
// errors so the live anchor step can still try (and enforce) anchoring.
if (e instanceof Error &&
e.message.startsWith("create_comment: could not find the selection")) {
(e.message.startsWith("create_comment: could not find the selection") ||
e.message.startsWith("create_comment: the suggestion's selection is ambiguous"))) {
throw e;
}
if (process.env.DEBUG) {
@@ -1958,10 +2002,19 @@ 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 = selection;
payload.selection = anchoredSelection ?? 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
@@ -1988,15 +2041,34 @@ 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);
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
// 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 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;
@@ -2014,10 +2086,13 @@ export class DocmostClient {
throw e;
}
if (!anchored) {
// Mutation aborted because the selection was not found in the live
// document. Roll back the comment and surface a hard error.
// 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.
await this.safeDeleteComment(newCommentId);
throw new Error("create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back");
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");
}
result.anchored = true;
return result;
+58 -55
View File
@@ -76,6 +76,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(spaces);
});
// Tool: list_pages
// INTENTIONAL per-transport divergence (not in the shared registry): this
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
// the in-app copy keeps the same tree option but is worded for the in-app agent.
// Kept per-layer so each side can tune its own guidance.
server.registerTool("list_pages", {
description: "List most recent pages in a space ordered by updatedAt (descending). " +
"Returns a bounded list (default 50, max 100) — use search for lookups " +
@@ -143,6 +147,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: table_insert_row
// NOT in the shared registry: this transport names the table argument `table`,
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
// one buildShape would rename a public MCP parameter, so the table row/cell
// tools stay per-transport by design.
server.registerTool("table_insert_row", {
description: "Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
"a block id inside it. `cells` = text per column (padded to the table's " +
@@ -159,6 +167,8 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: table_delete_row
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool("table_delete_row", {
description: "Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
"a block id inside it). Refuses to delete the table's only row. An " +
@@ -174,6 +184,8 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: table_update_cell
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool("table_update_cell", {
description: "Set the plain-text content of cell [row,col] (0-based) in a table " +
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
@@ -317,62 +329,17 @@ export function createDocmostMcpServer(config) {
},
};
});
// Tool: patch_node
server.registerTool("patch_node", {
description: "Replaces a single block identified by its attrs.id WITHOUT resending the " +
"whole document. Get the block id from get_page_json, then pass a " +
"ProseMirror node to put in its place. Example node: a paragraph " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
"JSON object or a JSON string (both accepted). Cheaper and safer than " +
"update_page_json for one-block structural edits.",
inputSchema: {
pageId: z.string().min(1),
nodeId: z.string().min(1),
node: z
.any()
.describe("ProseMirror node to put in place of the node with this id, e.g. " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
"JSON object or JSON string both accepted."),
},
}, async ({ pageId, nodeId, node }) => {
// Tool: patch_node — schema + description from the shared registry (identical
// across both transports). The execute body keeps its own parseNodeArg
// normalization (the model sometimes serializes `node` as a JSON string).
registerShared(SHARED_TOOL_SPECS.patchNode, async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
return jsonContent(result);
});
// Tool: insert_node
server.registerTool("insert_node", {
description: "Insert a block before/after another block (by attrs.id or anchor text) " +
"or append at the end. Get anchor block ids from get_page_json. Avoids " +
"resending the whole document. Can also insert table structure: to add a " +
"tableRow, pass a tableRow node with position before/after and anchor " +
"INSIDE the target table — anchorNodeId of any block/cell in it, or " +
"anchorText matching the table; to add a tableCell/tableHeader, use " +
"anchorNodeId of a block inside the target row (anchorText only resolves " +
"top-level blocks, so it cannot target a row). `anchorText` is matched " +
"against the block's literal rendered plain text (no markdown); " +
"markdown/emoji are tolerated as a fallback; prefer plain text or " +
"anchorNodeId. Note: append is top-level " +
"only and rejects structural table nodes. Example node: a paragraph " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
"JSON object or a JSON string (both accepted).",
inputSchema: {
pageId: z.string().min(1),
node: z
.any()
.describe("ProseMirror node to insert, e.g. " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
"JSON object or JSON string both accepted."),
position: z.enum(["before", "after", "append"]),
anchorNodeId: z.string().optional(),
anchorText: z.string().optional(),
},
}, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
// Tool: insert_node — schema + description from the shared registry. As with
// patch_node, the execute body retains parseNodeArg on the incoming node.
registerShared(SHARED_TOOL_SPECS.insertNode, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
position,
@@ -453,6 +420,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: share_page
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
// security-confirmation framing ("only share when the user explicitly asked,
// since this exposes the page to anyone with the link") tuned for the in-app
// agent; this transport keeps the plain public-URL wording.
server.registerTool("share_page", {
description: "Make a page publicly accessible (idempotent) and return its public " +
"URL. The URL format is <app>/share/<key>/p/<slugId>.",
@@ -539,6 +510,9 @@ export function createDocmostMcpServer(config) {
return jsonContent(comments);
});
// Tool: create_comment
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); this transport keeps its own wording.
server.registerTool("create_comment", {
description: "Create a new comment on a page. The comment is ALWAYS inline and is " +
"anchored to (highlights) its `selection` text — there are no page-level " +
@@ -546,7 +520,10 @@ 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.",
"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.",
inputSchema: {
pageId: z.string().describe("ID of the page to comment on"),
content: z.string().min(1).describe("Comment content in Markdown format"),
@@ -563,12 +540,30 @@ 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 }) => {
}, async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
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.");
}
const result = await docmostClient.createComment(pageId, content, "inline", selection, 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);
});
// Tool: update_comment
@@ -652,6 +647,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: search
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
// different schema (limit 1-20); this transport is a plain REST full-text search
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
server.registerTool("search", {
description: "Search for pages and content. Results are bounded by `limit` " +
"(default applied by the client, max 100).",
@@ -672,6 +671,10 @@ export function createDocmostMcpServer(config) {
return jsonContent(result);
});
// Tool: docmost_transform
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
// deliberately omits the `deleteComments` schema field (comment-deletion
// guardrail) and carries a much shorter description; this transport exposes the
// full helper catalogue. Different schema, so kept per-layer.
server.registerTool("docmost_transform", {
description: "Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
"against its LIVE ProseMirror document, with a diff preview and page " +
+132
View File
@@ -148,6 +148,67 @@ 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
@@ -210,6 +271,77 @@ 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,6 +70,11 @@ 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,6 +80,86 @@ export const SHARED_TOOL_SPECS = {
nodeId: z.string().min(1),
}),
},
// --- single-block structural write (patch / insert) ---
//
// CANONICAL description merges both layers: the MCP copy's "WITHOUT resending
// the whole document" + "cheaper/safer than a full-document replace" guidance
// AND the in-app copy's "keeps the same node id" + "Reversible via page
// history" framing — nothing either side conveyed is dropped. Sibling tools are
// named in transport-neutral prose ("the page-JSON view", "a full-document
// replace") to match the rest of the registry, since the two layers expose
// those siblings under different (snake_case vs camelCase) identifiers.
patchNode: {
mcpName: 'patch_node',
inAppKey: 'patchNode',
description: 'Replace a single content block identified by its attrs.id with a new ' +
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
'keeps the same node id. Get the block id from the page-JSON view, then ' +
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
'replacing the whole document for one-block structural edits. Reversible: ' +
'the previous version is kept in page history.',
buildShape: (z) => ({
pageId: z.string().min(1).describe('ID of the page containing the block'),
nodeId: z
.string()
.min(1)
.describe('attrs.id of the block to replace (from the page outline or ' +
'page-JSON view)'),
node: z
.any()
.describe('ProseMirror node to put in place of the node with this id, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.'),
}),
},
insertNode: {
mcpName: 'insert_node',
inAppKey: 'insertNode',
description: 'Insert a block before/after another block (by attrs.id or anchor text) ' +
'or append it at the end (top level). For before/after you MUST provide ' +
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
'page-JSON view. Avoids resending the whole document. Can also insert ' +
'table structure: to add a tableRow, pass a tableRow node with position ' +
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
'block/cell in it, or anchorText matching the table; to add a ' +
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' +
'(anchorText only resolves top-level blocks, so it cannot target a row). ' +
"`anchorText` is matched against the block's literal rendered plain text " +
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
'text or anchorNodeId. Note: append is top-level only and rejects ' +
'structural table nodes. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Reversible via page history.',
buildShape: (z) => ({
pageId: z.string().min(1),
node: z
.any()
.describe('ProseMirror node to insert, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.'),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z
.string()
.optional()
.describe('Anchor block id (for before/after).'),
anchorText: z
.string()
.optional()
.describe("Anchor text fragment (for before/after), matched against the " +
"block's literal rendered plain text (no markdown). Markdown/emoji " +
'are tolerated as a fallback; prefer plain text or anchorNodeId.'),
}),
},
// --- share management ---
unsharePage: {
mcpName: 'unshare_page',
+103 -10
View File
@@ -56,7 +56,12 @@ import {
} from "./lib/json-edit.js";
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
import { diffDocs, summarizeChange } from "./lib/diff.js";
import { applyAnchorInDoc, canAnchorInDoc } from "./lib/comment-anchor.js";
import {
applyAnchorInDoc,
canAnchorInDoc,
countAnchorMatches,
getAnchoredText,
} from "./lib/comment-anchor.js";
import {
blockText,
walk,
@@ -2395,10 +2400,28 @@ 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.
@@ -2411,6 +2434,17 @@ 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
@@ -2418,18 +2452,45 @@ export class DocmostClient {
if (!isReply && selection) {
try {
const page = await this.getPageJson(pageId);
if (!canAnchorInDoc(page.content, selection)) {
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)) {
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" error; swallow read/network errors so the
// live anchor step can still try (and enforce) the anchoring.
// Rethrow our own "not found"/"ambiguous" errors; swallow read/network
// errors so the live anchor step can still try (and enforce) anchoring.
if (
e instanceof Error &&
e.message.startsWith("create_comment: could not find the selection")
(e.message.startsWith("create_comment: could not find the selection") ||
e.message.startsWith(
"create_comment: the suggestion's selection is ambiguous",
))
) {
throw e;
}
@@ -2452,8 +2513,18 @@ export class DocmostClient {
content: JSON.stringify(jsonContent),
type: effectiveType,
};
if (!isReply && selection) payload.selection = selection;
// 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 (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;
@@ -2485,12 +2556,18 @@ 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);
const mutation = await mutatePageContent(
// 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,
@@ -2499,6 +2576,19 @@ 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;
@@ -2517,11 +2607,14 @@ export class DocmostClient {
}
if (!anchored) {
// Mutation aborted because the selection was not found in the live
// document. Roll back the comment and surface a hard error.
// 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.
await this.safeDeleteComment(newCommentId);
throw new Error(
"create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
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",
);
}
+66 -64
View File
@@ -105,6 +105,10 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
});
// Tool: list_pages
// INTENTIONAL per-transport divergence (not in the shared registry): this
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
// the in-app copy keeps the same tree option but is worded for the in-app agent.
// Kept per-layer so each side can tune its own guidance.
server.registerTool(
"list_pages",
{
@@ -195,6 +199,10 @@ server.registerTool(
);
// Tool: table_insert_row
// NOT in the shared registry: this transport names the table argument `table`,
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
// one buildShape would rename a public MCP parameter, so the table row/cell
// tools stay per-transport by design.
server.registerTool(
"table_insert_row",
{
@@ -222,6 +230,8 @@ server.registerTool(
);
// Tool: table_delete_row
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool(
"table_delete_row",
{
@@ -243,6 +253,8 @@ server.registerTool(
);
// Tool: table_update_cell
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool(
"table_update_cell",
{
@@ -445,32 +457,11 @@ server.registerTool(
},
);
// Tool: patch_node
server.registerTool(
"patch_node",
{
description:
"Replaces a single block identified by its attrs.id WITHOUT resending the " +
"whole document. Get the block id from get_page_json, then pass a " +
"ProseMirror node to put in its place. Example node: a paragraph " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
"JSON object or a JSON string (both accepted). Cheaper and safer than " +
"update_page_json for one-block structural edits.",
inputSchema: {
pageId: z.string().min(1),
nodeId: z.string().min(1),
node: z
.any()
.describe(
"ProseMirror node to put in place of the node with this id, e.g. " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
"JSON object or JSON string both accepted.",
),
},
},
// Tool: patch_node — schema + description from the shared registry (identical
// across both transports). The execute body keeps its own parseNodeArg
// normalization (the model sometimes serializes `node` as a JSON string).
registerShared(
SHARED_TOOL_SPECS.patchNode,
async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
@@ -478,42 +469,10 @@ server.registerTool(
},
);
// Tool: insert_node
server.registerTool(
"insert_node",
{
description:
"Insert a block before/after another block (by attrs.id or anchor text) " +
"or append at the end. Get anchor block ids from get_page_json. Avoids " +
"resending the whole document. Can also insert table structure: to add a " +
"tableRow, pass a tableRow node with position before/after and anchor " +
"INSIDE the target table — anchorNodeId of any block/cell in it, or " +
"anchorText matching the table; to add a tableCell/tableHeader, use " +
"anchorNodeId of a block inside the target row (anchorText only resolves " +
"top-level blocks, so it cannot target a row). `anchorText` is matched " +
"against the block's literal rendered plain text (no markdown); " +
"markdown/emoji are tolerated as a fallback; prefer plain text or " +
"anchorNodeId. Note: append is top-level " +
"only and rejects structural table nodes. Example node: a paragraph " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
"JSON object or a JSON string (both accepted).",
inputSchema: {
pageId: z.string().min(1),
node: z
.any()
.describe(
"ProseMirror node to insert, e.g. " +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
"JSON object or JSON string both accepted.",
),
position: z.enum(["before", "after", "append"]),
anchorNodeId: z.string().optional(),
anchorText: z.string().optional(),
},
},
// Tool: insert_node — schema + description from the shared registry. As with
// patch_node, the execute body retains parseNodeArg on the incoming node.
registerShared(
SHARED_TOOL_SPECS.insertNode,
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
@@ -619,6 +578,10 @@ server.registerTool(
);
// Tool: share_page
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
// security-confirmation framing ("only share when the user explicitly asked,
// since this exposes the page to anyone with the link") tuned for the in-app
// agent; this transport keeps the plain public-URL wording.
server.registerTool(
"share_page",
{
@@ -746,6 +709,9 @@ server.registerTool(
);
// Tool: create_comment
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); this transport keeps its own wording.
server.registerTool(
"create_comment",
{
@@ -756,7 +722,10 @@ 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.",
"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.",
inputSchema: {
pageId: z.string().describe("ID of the page to comment on"),
content: z.string().min(1).describe("Comment content in Markdown format"),
@@ -775,20 +744,45 @@ 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 }) => {
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
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);
},
@@ -911,6 +905,10 @@ server.registerTool(
);
// Tool: search
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
// different schema (limit 1-20); this transport is a plain REST full-text search
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
server.registerTool(
"search",
{
@@ -937,6 +935,10 @@ server.registerTool(
);
// Tool: docmost_transform
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
// deliberately omits the `deleteComments` schema field (comment-deletion
// guardrail) and carries a much shorter description; this transport exposes the
// full helper catalogue. Different schema, so kept per-layer.
server.registerTool(
"docmost_transform",
{
+127
View File
@@ -171,6 +171,65 @@ 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
@@ -242,6 +301,74 @@ 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,6 +75,11 @@ 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,6 +119,98 @@ export const SHARED_TOOL_SPECS = {
}),
},
// --- single-block structural write (patch / insert) ---
//
// CANONICAL description merges both layers: the MCP copy's "WITHOUT resending
// the whole document" + "cheaper/safer than a full-document replace" guidance
// AND the in-app copy's "keeps the same node id" + "Reversible via page
// history" framing — nothing either side conveyed is dropped. Sibling tools are
// named in transport-neutral prose ("the page-JSON view", "a full-document
// replace") to match the rest of the registry, since the two layers expose
// those siblings under different (snake_case vs camelCase) identifiers.
patchNode: {
mcpName: 'patch_node',
inAppKey: 'patchNode',
description:
'Replace a single content block identified by its attrs.id with a new ' +
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
'keeps the same node id. Get the block id from the page-JSON view, then ' +
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
'replacing the whole document for one-block structural edits. Reversible: ' +
'the previous version is kept in page history.',
buildShape: (z) => ({
pageId: z.string().min(1).describe('ID of the page containing the block'),
nodeId: z
.string()
.min(1)
.describe(
'attrs.id of the block to replace (from the page outline or ' +
'page-JSON view)',
),
node: z
.any()
.describe(
'ProseMirror node to put in place of the node with this id, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.',
),
}),
},
insertNode: {
mcpName: 'insert_node',
inAppKey: 'insertNode',
description:
'Insert a block before/after another block (by attrs.id or anchor text) ' +
'or append it at the end (top level). For before/after you MUST provide ' +
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
'page-JSON view. Avoids resending the whole document. Can also insert ' +
'table structure: to add a tableRow, pass a tableRow node with position ' +
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
'block/cell in it, or anchorText matching the table; to add a ' +
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' +
'(anchorText only resolves top-level blocks, so it cannot target a row). ' +
"`anchorText` is matched against the block's literal rendered plain text " +
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
'text or anchorNodeId. Note: append is top-level only and rejects ' +
'structural table nodes. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Reversible via page history.',
buildShape: (z) => ({
pageId: z.string().min(1),
node: z
.any()
.describe(
'ProseMirror node to insert, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.',
),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z
.string()
.optional()
.describe('Anchor block id (for before/after).'),
anchorText: z
.string()
.optional()
.describe(
"Anchor text fragment (for before/after), matched against the " +
"block's literal rendered plain text (no markdown). Markdown/emoji " +
'are tolerated as a fallback; prefer plain text or anchorNodeId.',
),
}),
},
// --- share management ---
unsharePage: {
@@ -229,3 +229,322 @@ 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,6 +6,8 @@ import {
findAnchorInBlock,
canAnchorInDoc,
applyAnchorInDoc,
countAnchorMatches,
getAnchoredText,
} from "../../build/lib/comment-anchor.js";
const COMMENT_ID = "cmt-123";
@@ -208,3 +210,101 @@ 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,6 +83,63 @@ test("getNode builder produces exactly { pageId, nodeId }", () => {
assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]);
});
test("patchNode spec exists, merges BOTH descriptions, builds { pageId, nodeId, node }", () => {
const spec = SHARED_TOOL_SPECS.patchNode;
assert.ok(spec, "patchNode spec missing");
assert.equal(spec.mcpName, "patch_node");
assert.equal(spec.inAppKey, "patchNode");
// The canonical description must carry the key guidance from BOTH originals:
// - MCP-only: "WITHOUT resending the whole document" + the cheaper/safer note.
// - in-app-only: "keeps the same node id" + the "Reversible ... page history"
// framing the MCP copy lacked.
assert.match(spec.description, /WITHOUT resending the whole document/);
assert.match(spec.description, /Cheaper and safer/);
assert.match(spec.description, /keeps the same node id/i);
assert.match(spec.description, /Reversible/i);
assert.match(spec.description, /page history/i);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["node", "nodeId", "pageId"]);
// A minimal valid input parses (node accepts an arbitrary object via z.any()).
const parsed = z.object(shape).parse({
pageId: "p1",
nodeId: "n1",
node: { type: "paragraph" },
});
assert.equal(parsed.pageId, "p1");
assert.equal(parsed.nodeId, "n1");
});
test("insertNode spec exists, merges BOTH descriptions, builds the full anchor shape", () => {
const spec = SHARED_TOOL_SPECS.insertNode;
assert.ok(spec, "insertNode spec missing");
assert.equal(spec.mcpName, "insert_node");
assert.equal(spec.inAppKey, "insertNode");
// Canonical description must keep BOTH sides' nuance:
// - in-app-only: "EXACTLY ONE of anchorNodeId or anchorText" + "Reversible".
// - MCP-only: the table-structure (tableRow/tableCell) insertion guidance.
assert.match(spec.description, /EXACTLY ONE of anchorNodeId or anchorText/);
assert.match(spec.description, /tableRow/);
assert.match(spec.description, /append is top-level only/);
assert.match(spec.description, /Reversible via page history/);
const shape = spec.buildShape(z);
assert.deepEqual(
Object.keys(shape).sort(),
["anchorNodeId", "anchorText", "node", "pageId", "position"],
);
// before/after/append are the only accepted positions; anchors are optional.
const schema = z.object(shape);
assert.doesNotThrow(() =>
schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }),
);
assert.throws(() =>
schema.parse({ pageId: "p1", node: {}, position: "sideways" }),
);
});
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);