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>
This commit is contained in:
@@ -379,6 +379,32 @@ describe('replaceYjsMarkedText', () => {
|
||||
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 } },
|
||||
|
||||
Reference in New Issue
Block a user