diff --git a/packages/views/editor/extensions/mention-extension.test.ts b/packages/views/editor/extensions/mention-extension.test.ts new file mode 100644 index 000000000..15542752c --- /dev/null +++ b/packages/views/editor/extensions/mention-extension.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { BaseMentionExtension } from "./mention-extension"; + +const tokenizer = BaseMentionExtension.config.markdownTokenizer!; + +// The tiptap MarkdownTokenizer/renderMarkdown types have broad signatures +// (multi-arg overloads). Our extension always provides single-argument +// implementations, so cast for test convenience. +const startFn = tokenizer.start as (src: string) => number; +const tokenizeFn = tokenizer.tokenize as ( + src: string, +) => { type: string; raw: string; attributes: Record } | undefined; +const renderMarkdown = BaseMentionExtension.config.renderMarkdown as ( + node: { attrs: Record }, +) => string; + +function tokenize(src: string) { + const start = startFn(src); + if (start === -1) return undefined; + return tokenizeFn(src.slice(start)); +} + +describe("mention tokenizer", () => { + it("parses a plain mention", () => { + const token = tokenize("[@Alice](mention://member/aaa-bbb)"); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe("Alice"); + expect(token!.attributes.type).toBe("member"); + expect(token!.attributes.id).toBe("aaa-bbb"); + }); + + it("parses a mention with escaped brackets (round-trip from renderMarkdown)", () => { + // renderMarkdown escapes brackets: David[TF] → David\[TF\] + const md = renderMarkdown({ + attrs: { id: "aaa-bbb", label: "David[TF]", type: "agent" }, + }); + expect(md).toBe("[@David\\[TF\\]](mention://agent/aaa-bbb)"); + + const token = tokenize(md); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe("David[TF]"); + expect(token!.attributes.type).toBe("agent"); + }); + + it("does not match an ordinary Markdown link before a mention", () => { + const src = + "Check [docs](https://example.com) - [@User](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)"; + + // start() must NOT land on the [docs] link at index 6 + const start = startFn(src); + expect(start).toBeGreaterThan(6); + + // tokenize from the correct start position + const token = tokenizeFn(src.slice(start)); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe("User"); + expect(token!.attributes.id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + }); + + it("handles multiple ordinary links before a mention", () => { + const src = + "See [a](https://a.com) and [b](https://b.com) - [@Bot](mention://agent/abc-123)"; + const start = startFn(src); + const token = tokenizeFn(src.slice(start)); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe("Bot"); + }); + + it("round-trips an agent label with nested brackets", () => { + const md = renderMarkdown({ + attrs: { id: "x-y-z", label: "Bot[v2][beta]", type: "agent" }, + }); + const token = tokenize(md); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe("Bot[v2][beta]"); + }); + + it("parses issue mentions without @ prefix", () => { + const token = tokenize("[MUL-123](mention://issue/aaa-bbb)"); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe("MUL-123"); + expect(token!.attributes.type).toBe("issue"); + }); +}); diff --git a/packages/views/editor/extensions/mention-extension.ts b/packages/views/editor/extensions/mention-extension.ts index 4b1bafe96..380b0be93 100644 --- a/packages/views/editor/extensions/mention-extension.ts +++ b/packages/views/editor/extensions/mention-extension.ts @@ -39,17 +39,25 @@ export const BaseMentionExtension = Mention.extend({ name: "mention", level: "inline" as const, start(src: string) { - return src.search(/\[@?[^\]]+\]\(mention:\/\//); + // Accept escaped brackets (\\[ \\]) and non-] chars in the label. + // This prevents matching ordinary Markdown links like [docs](url) + // that appear before a mention on the same line. + return src.search(/\[@?(?:\\.|[^\]])+\]\(mention:\/\//); }, tokenize(src: string) { + // Label accepts escaped chars (\\[ \\]) or any non-] character. + // This prevents the label from crossing a ]( Markdown link boundary + // while still supporting bracket-containing names like "David\[TF\]". const match = src.match( - /^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/, + /^\[@?((?:\\.|[^\]])+)\]\(mention:\/\/(\w+)\/([^)]+)\)/, ); if (!match) return undefined; + // Unescape backslash-escaped brackets that renderMarkdown may produce. + const rawLabel = match[1]?.replace(/\\\[/g, "[").replace(/\\\]/g, "]"); return { type: "mention", raw: match[0], - attributes: { label: match[1], type: match[2] ?? "member", id: match[3] }, + attributes: { label: rawLabel, type: match[2] ?? "member", id: match[3] }, }; }, }, @@ -59,6 +67,9 @@ export const BaseMentionExtension = Mention.extend({ renderMarkdown: (node: any) => { const { id, label, type = "member" } = node.attrs || {}; const prefix = type === "issue" ? "" : "@"; - return `[${prefix}${label ?? id}](mention://${type}/${id})`; + // Escape square brackets in the label so the markdown link syntax + // is not broken when the name contains [ or ] (e.g. "David[TF]"). + const safeLabel = (label ?? id).replace(/\[/g, "\\[").replace(/\]/g, "\\]"); + return `[${prefix}${safeLabel}](mention://${type}/${id})`; }, }); diff --git a/server/internal/util/mention.go b/server/internal/util/mention.go index b15e07814..caf132f4f 100644 --- a/server/internal/util/mention.go +++ b/server/internal/util/mention.go @@ -10,7 +10,10 @@ type Mention struct { // MentionRe matches [@Label](mention://type/id) or [Label](mention://issue/id) in markdown. // The @ prefix is optional to support issue mentions which use [MUL-123](mention://issue/...). -var MentionRe = regexp.MustCompile(`\[@?[^\]]*\]\(mention://(member|agent|issue|all)/([0-9a-fA-F-]+|all)\)`) +// Uses .+? (non-greedy) instead of [^\]]* so labels containing square brackets +// (e.g. "David[TF]") are matched correctly — the ](mention:// anchor is specific +// enough to prevent over-matching. +var MentionRe = regexp.MustCompile(`\[@?(.+?)\]\(mention://(member|agent|issue|all)/([0-9a-fA-F-]+|all)\)`) // IsMentionAll returns true if the mention is an @all mention. func (m Mention) IsMentionAll() bool { @@ -23,12 +26,12 @@ func ParseMentions(content string) []Mention { seen := make(map[string]bool) var result []Mention for _, m := range matches { - key := m[1] + ":" + m[2] + key := m[2] + ":" + m[3] if seen[key] { continue } seen[key] = true - result = append(result, Mention{Type: m[1], ID: m[2]}) + result = append(result, Mention{Type: m[2], ID: m[3]}) } return result } diff --git a/server/internal/util/mention_test.go b/server/internal/util/mention_test.go new file mode 100644 index 000000000..49957e222 --- /dev/null +++ b/server/internal/util/mention_test.go @@ -0,0 +1,101 @@ +package util + +import ( + "testing" +) + +func TestParseMentions(t *testing.T) { + tests := []struct { + name string + content string + want []Mention + }{ + { + name: "simple agent mention", + content: "[@Agent](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) please fix", + want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}, + }, + { + name: "agent name with square brackets", + content: "[@David[TF]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) please fix", + want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}, + }, + { + name: "agent name with nested brackets", + content: "[@Bot[v2][beta]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) help", + want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}, + }, + { + name: "multiple mentions with brackets", + content: "[@A[1]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) and [@B[2]](mention://agent/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb)", + want: []Mention{ + {Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}, + {Type: "agent", ID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}, + }, + }, + { + name: "issue mention without @", + content: "[MUL-123](mention://issue/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) is related", + want: []Mention{{Type: "issue", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}, + }, + { + name: "member mention", + content: "[@Bob](mention://member/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) look", + want: []Mention{{Type: "member", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}, + }, + { + name: "all mention", + content: "[@All](mention://all/all) heads up", + want: []Mention{{Type: "all", ID: "all"}}, + }, + { + name: "deduplicate same mention", + content: "[@A](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) and again [@A](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)", + want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}, + }, + { + name: "no mentions", + content: "just a plain comment", + want: nil, + }, + { + name: "escaped brackets in label", + content: `[@David\[TF\]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) hi`, + want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseMentions(tt.content) + if len(got) != len(tt.want) { + t.Fatalf("ParseMentions() returned %d mentions, want %d\ngot: %+v\nwant: %+v", len(got), len(tt.want), got, tt.want) + } + for i := range got { + if got[i].Type != tt.want[i].Type || got[i].ID != tt.want[i].ID { + t.Errorf("mention[%d] = %+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestHasMentionAll(t *testing.T) { + tests := []struct { + name string + mentions []Mention + want bool + }{ + {"empty", nil, false}, + {"no all", []Mention{{Type: "agent", ID: "x"}}, false}, + {"has all", []Mention{{Type: "all", ID: "all"}}, true}, + {"mixed", []Mention{{Type: "agent", ID: "x"}, {Type: "all", ID: "all"}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HasMentionAll(tt.mentions); got != tt.want { + t.Errorf("HasMentionAll() = %v, want %v", got, tt.want) + } + }) + } +}