bcd194ee5d
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>
161 lines
5.5 KiB
JavaScript
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");
|
|
});
|