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:
Kagura
2026-05-03 19:39:26 +08:00
committed by GitHub
parent a039c4d803
commit cc94fbd305
4 changed files with 206 additions and 7 deletions

View 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");
});
});

View File

@@ -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})`;
},
});

View File

@@ -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
}

View 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)
}
})
}
}