Files
gitmost/packages/prosemirror-markdown/test/resolved-comment-anchors.test.ts
T
claude code agent 227 bcd194ee5d feat(mcp): hide resolved-comment anchors + feed from the agent (#328)
The AI agent (MCP + in-app chat) saw ALL comments incl. resolved via two
channels, cluttering its context and breaking fragment search. Default now:
the agent sees only ACTIVE discussions; resolved is opt-in. Active anchors and
threads are always kept.

Channel 1 — resolved comment anchors on agent reads (converter option):
`convertProseMirrorToMarkdown(content, options?)` gains
`options.dropResolvedCommentAnchors` (default false — zero change for every
existing caller incl. git-sync). Both `case "comment"` emitters (top-level and
the raw-HTML inlineToHtml path) emit BARE text (no `<span data-comment-id>`) when
`resolved && the flag`; active anchors keep their wrapper. mcp `getPage` passes
the flag; `export_page_markdown` does NOT (lossless export must preserve resolved
anchors — that is why it is an opt-in option, not unconditional); `get_page_json`
is untouched (lossless PM JSON). Built on the #293 package converter.

Channel 2 — `list_comments` default active-only: `listComments(pageId,
includeResolved=false)` now returns `{ items, resolvedThreadsHidden }` (was a
bare array). By default a RESOLVED top-level thread is hidden wholesale — the
root AND every reply anchored to it (a thread is gated only by its root's
resolvedAt; a resolved reply under an ACTIVE root stays). `resolvedThreadsHidden`
counts hidden threads so the agent knows to re-query. `includeResolved:true`
returns everything. The `includeResolved` param is added to both tool
registrations (MCP index.ts + in-app ai-chat-tools.service.ts); `DocmostClientLike`
signature updated. Server `findPageComments` is NOT touched — the web UI's tabs
depend on the full feed; filtering is only at the mcp-client level. All internal
call sites (export_page_markdown / checkNewComments / transformPage) updated to
`.items` with `includeResolved:true` to keep their full-feed behavior.

The comment model is assumed FLAT (a reply's parentCommentId points at the
thread root) — documented in the filter; a future reply-of-reply model would
need a root-walk there.

Tests: resolved-comment-anchors.test.ts (6 — anchor dropped with flag / kept
without, for BOTH emitters; active always kept); list-comments-resolved.test.mjs
(4 — resolved thread+reply hidden + counter; includeResolved:true returns all;
an ACTIVE thread with a RESOLVED reply is NOT hidden).

package vitest: 664 passed; tsc clean. mcp: node --test 458 passed; tsc clean.
apps/server + git-sync: tsc clean (converter option default-off).

NOTE: based on feat/293-B (#293/#326 STEP 5) — the converter lives in the
package; this PR is stacked on #333 and its base retargets to develop once #333
merges. mcp/build is gitignored (not committed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:26:43 +03:00

96 lines
3.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
// Import the converter DIRECTLY from src (NOT the docmost-client barrel, which
// pulls in collaboration.ts and mutates the global DOM at import time), matching
// the other converter unit tests (see markdown-converter-html-marks.test.ts).
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
// gitmost #328 Channel 1: the `dropResolvedCommentAnchors` converter option
// hides RESOLVED comment anchors from agent reads while keeping ACTIVE anchors.
// The option defaults to false (zero behavior change for the lossless git-sync
// export path). Two emitters read it: the top-level marks loop and the raw-HTML
// inlineToHtml path (inside columns / spanned table cells).
const text = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
const commentMark = (commentId: string, resolved: boolean) => ({
type: 'comment',
attrs: { commentId, resolved },
});
// A columns node (raw-HTML container) so its children render via the
// blockToHtml -> inlineToHtml path (the SECOND `case "comment"` emitter).
const oneColumn = (...blocks: any[]) => ({
type: 'columns',
attrs: { layout: 'two' },
content: [{ type: 'column', content: blocks }],
});
describe('#328 Channel 1 — top-level emitter: dropResolvedCommentAnchors', () => {
const resolvedDoc = doc(
para(text('kept '), text('resolved', [commentMark('r1', true)])),
);
const activeDoc = doc(
para(text('kept '), text('active', [commentMark('a1', false)])),
);
it('drops a RESOLVED anchor (bare text) WITH the flag', () => {
const out = convertProseMirrorToMarkdown(resolvedDoc, {
dropResolvedCommentAnchors: true,
});
expect(out).toBe('kept resolved');
expect(out).not.toContain('data-comment-id');
});
it('PRESERVES a RESOLVED anchor WITHOUT the flag (default off)', () => {
const out = convertProseMirrorToMarkdown(resolvedDoc);
expect(out).toContain(
'<span data-comment-id="r1" data-resolved="true">resolved</span>',
);
});
it('KEEPS an ACTIVE anchor in BOTH cases', () => {
const withFlag = convertProseMirrorToMarkdown(activeDoc, {
dropResolvedCommentAnchors: true,
});
const withoutFlag = convertProseMirrorToMarkdown(activeDoc);
expect(withFlag).toContain('<span data-comment-id="a1">active</span>');
expect(withoutFlag).toContain('<span data-comment-id="a1">active</span>');
});
});
describe('#328 Channel 1 — raw-HTML inlineToHtml emitter (columns)', () => {
const resolvedCol = doc(
oneColumn(para(text('resolved', [commentMark('r1', true)]))),
);
const activeCol = doc(
oneColumn(para(text('active', [commentMark('a1', false)]))),
);
it('drops a RESOLVED anchor (bare text) WITH the flag', () => {
const out = convertProseMirrorToMarkdown(resolvedCol, {
dropResolvedCommentAnchors: true,
});
expect(out).toContain('<p>resolved</p>');
expect(out).not.toContain('data-comment-id');
});
it('PRESERVES a RESOLVED anchor WITHOUT the flag', () => {
const out = convertProseMirrorToMarkdown(resolvedCol);
expect(out).toContain(
'<span data-comment-id="r1" data-resolved="true">resolved</span>',
);
});
it('KEEPS an ACTIVE anchor in BOTH cases', () => {
const withFlag = convertProseMirrorToMarkdown(activeCol, {
dropResolvedCommentAnchors: true,
});
const withoutFlag = convertProseMirrorToMarkdown(activeCol);
expect(withFlag).toContain('<span data-comment-id="a1">active</span>');
expect(withoutFlag).toContain('<span data-comment-id="a1">active</span>');
});
});