mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
fix: handle square brackets in agent names for mention parsing (#1992)
* fix: handle square brackets in agent names for mention parsing (#1991) The mention regex used [^\]]* to match labels, which broke when agent names contained square brackets (e.g. David[TF]). The ] inside the name caused the regex to stop matching prematurely, silently dropping the mention. Changes: - Backend (mention.go): Switch to .+? (non-greedy) anchored on ](mention:// to correctly match labels with brackets - Frontend (mention-extension.ts): Same regex fix in tokenizer, plus escape [ and ] in renderMarkdown to prevent creating ambiguous markdown syntax - Add comprehensive tests for ParseMentions covering bracket names Fixes #1991 * fix: add optional chaining for match group access Fixes TS2532: Object is possibly 'undefined' on match[1] when calling .replace() in the mention tokenizer. * fix: tighten mention tokenizer to reject ordinary Markdown links - Replace .+? with (?:\\.|[^\]])+ in start() and tokenize() regexes so the label cannot cross a ]( Markdown link boundary - Escaped brackets (\[ \]) from renderMarkdown() are still accepted - Add frontend tokenizer/serializer round-trip tests: - Plain mention - Escaped brackets (David[TF]) round-trip - Normal Markdown link + mention on same line (regression) - Multiple links before mention - Nested brackets (Bot[v2][beta]) - Issue mentions without @ prefix Addresses review feedback on #1992. * fix: add type assertions for tiptap MarkdownTokenizer interface in tests The tiptap MarkdownTokenizer type allows start to be string | function and tokenize to accept 3 arguments. Our extension always provides single-arg functions, so cast them for TypeScript satisfaction. Fixes CI typecheck failure in @multica/views package. * fix: cast renderMarkdown to single-arg shape and reset file modes to 0644
This commit is contained in:
84
packages/views/editor/extensions/mention-extension.test.ts
Normal file
84
packages/views/editor/extensions/mention-extension.test.ts
Normal file
@@ -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<string, string> } | undefined;
|
||||
const renderMarkdown = BaseMentionExtension.config.renderMarkdown as (
|
||||
node: { attrs: Record<string, string> },
|
||||
) => 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");
|
||||
});
|
||||
});
|
||||
@@ -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})`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
101
server/internal/util/mention_test.go
Normal file
101
server/internal/util/mention_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user