Files
gitmost/packages/mcp/test/mock/list-comments-resolved.test.mjs
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

161 lines
5.5 KiB
JavaScript

// gitmost #328 Channel 2: DocmostClient.listComments hides RESOLVED THREADS
// wholesale by default (a resolved top-level comment AND every reply under it),
// returning `{ items, resolvedThreadsHidden }`. `includeResolved: true` returns
// the full feed. These tests stand a local http.createServer in for Docmost and
// mock the /auth/login + /comments (paginated) routes.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function closeServer(server) {
return new Promise((resolve) => server.close(resolve));
}
function sendJson(res, status, obj, extraHeaders = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
res.end(JSON.stringify(obj));
}
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { server, baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => closeServer(s)));
});
// A minimal ProseMirror comment body (a paragraph of text).
const body = (t) => ({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
});
// Feed: an ACTIVE thread whose REPLY is resolved (root active, reply resolved —
// the thread must STAY, because a thread is gated only by its ROOT's resolvedAt)
// and a RESOLVED thread (root + reply).
const FEED = [
{
id: "a",
pageId: "page-1",
parentCommentId: null,
resolvedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
creatorId: "u1",
content: body("active root"),
},
{
id: "a1",
pageId: "page-1",
parentCommentId: "a",
// A RESOLVED reply under an ACTIVE root: the thread is NOT hidden (only a
// resolved ROOT hides a thread), so this reply survives the default filter.
resolvedAt: "2026-02-15T00:00:00.000Z",
createdAt: "2026-01-01T01:00:00.000Z",
creatorId: "u1",
content: body("resolved reply of an active thread"),
},
{
id: "r",
pageId: "page-1",
parentCommentId: null,
resolvedAt: "2026-02-01T00:00:00.000Z",
createdAt: "2026-01-02T00:00:00.000Z",
creatorId: "u1",
content: body("resolved root"),
},
{
id: "r1",
pageId: "page-1",
parentCommentId: "r",
resolvedAt: null,
createdAt: "2026-01-02T01:00:00.000Z",
creatorId: "u1",
content: body("resolved reply"),
},
];
function commentsServer() {
return 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/comments") {
// Single page, no cursor.
sendJson(res, 200, { data: { items: FEED, meta: { nextCursor: null } } });
return;
}
sendJson(res, 404, { message: "not found" });
});
}
test("default hides the resolved thread (root + its reply) and counts it", async () => {
const { baseURL } = await commentsServer();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.listComments("page-1");
assert.equal(Array.isArray(result.items), true, "returns { items, ... }");
const ids = result.items.map((c) => c.id).sort();
assert.deepEqual(ids, ["a", "a1"], "only the active thread remains");
assert.equal(result.resolvedThreadsHidden, 1, "one resolved thread hidden");
});
test("includeResolved:true returns EVERYTHING with zero hidden", async () => {
const { baseURL } = await commentsServer();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.listComments("page-1", true);
const ids = result.items.map((c) => c.id).sort();
assert.deepEqual(ids, ["a", "a1", "r", "r1"], "all four comments returned");
assert.equal(result.resolvedThreadsHidden, 0, "nothing hidden with the flag");
});
test("the reply of a resolved thread is hidden with the thread", async () => {
const { baseURL } = await commentsServer();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.listComments("page-1");
const ids = result.items.map((c) => c.id);
assert.equal(ids.includes("r1"), false, "the resolved thread's reply is gone");
assert.equal(ids.includes("r"), false, "the resolved root is gone");
});
test("an ACTIVE thread whose REPLY is resolved is NOT hidden", async () => {
// A thread is gated only by its ROOT's resolvedAt. `a1` is a resolved reply
// under the active root `a`, so both must survive the default filter and the
// thread must not be counted as hidden.
const { baseURL } = await commentsServer();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.listComments("page-1");
const ids = result.items.map((c) => c.id).sort();
assert.equal(ids.includes("a"), true, "active root stays");
assert.equal(ids.includes("a1"), true, "its resolved reply stays with the thread");
assert.equal(result.resolvedThreadsHidden, 1, "only the resolved-root thread is hidden");
});