diff --git a/cmd/ggcode/root.go b/cmd/ggcode/root.go index 4dbf7216f..b3b2d7f9c 100644 --- a/cmd/ggcode/root.go +++ b/cmd/ggcode/root.go @@ -525,10 +525,15 @@ func run(cfg *config.Config, cfgFile, resumeID string, bypass bool) error { projectAutoMem := core.ProjectAutoMem saveMemoryTool := core.SaveMemoryTool startupAssets := core.StartupAssets - // Startup notices (sa-45): merge MCP gate/merge warnings with - // sleep-time consolidation findings and hand the combined list to the - // TUI so they render as system messages on the first frame. - startupNotices := append(core.StartupNotices, startupAssets.ConsolidationFindings...) + // Startup notices (sa-45): MCP gate/merge warnings render as system + // messages on the first frame. Sleep-time consolidation findings are + // deliberately NOT appended anymore: they are agent-facing advisory + // (already injected into the system prompt via RepairSuggestions, + // agentruntime/prompt.go) - showing them in the TUI only surfaced + // regex-level false positives (broken-path heuristics on tool names / + // cross-project paths) as user-visible noise. User stays in the loop + // when the agent acts on a suggestion (save_memory/delete_memory). + startupNotices := core.StartupNotices autoFiles := startupAssets.AutoFiles commandMgr := startupAssets.CommandManager trace.Mark("build interactive runtime core") diff --git a/internal/im/mattermost_adapter.go b/internal/im/mattermost_adapter.go index 005c4f369..05db846ee 100644 --- a/internal/im/mattermost_adapter.go +++ b/internal/im/mattermost_adapter.go @@ -11,7 +11,6 @@ import ( "net/http" "net/textproto" "os" - "regexp" "strings" "sync" "time" @@ -404,6 +403,9 @@ func (a *mattermostAdapter) handleWSEvent(ctx context.Context, event map[string] if !isFree && a.requireMention { hasMention := a.hasMention(messageText) if !hasMention { + // #4322: previously this drop was silent, making trailing "-"/"." + // usernames undiagnosable; keep a debug trace for the rest. + debug.Log("mattermost", "adapter=%s channel %s message without mention dropped", a.name, channelID) return } messageText = a.stripMention(messageText) @@ -467,15 +469,72 @@ func (a *mattermostAdapter) handleWSEvent(ctx context.Context, event map[string] } } +// mattermostUsernameContChars are the characters that may legally continue +// a Mattermost username after a prefix (#4322): server charset is +// ^[a-z0-9.\-_]+$ (mattermost/server validUsernameChars), so if the byte +// following a candidate match is one of these, the match is a strict PREFIX +// of a longer username and must not count. +const mattermostUsernameContChars = "abcdefghijklmnopqrstuvwxyz0123456789._-" + +// mattermostIsUsernameContByte reports whether b may continue a username. +func mattermostIsUsernameContByte(b byte) bool { + return strings.IndexByte(mattermostUsernameContChars, b) >= 0 +} + +// mentionsMattermostHandle reports whether text contains "@handle" NOT +// followed by a legal username continuation character. RE2's \b is an +// ASCII word boundary that can never match when the handle ends with "-" +// or "." (legal username endings like "dev-", "ops1."), so the boundary is +// a post-match check instead (#4322, same fix shape as Matrix #2749). +// The short-name guard from #963 is preserved: "al" does not match "@alex" +// because "e" is a continuation byte. +func mentionsMattermostHandle(lower, handle string) bool { + needle := "@" + strings.ToLower(handle) + for start := 0; ; { + i := strings.Index(lower[start:], needle) + if i < 0 { + return false + } + at := start + i + end := at + len(needle) + if end >= len(lower) || !mattermostIsUsernameContByte(lower[end]) { + return true + } + start = at + 1 + } +} + +// stripMattermostHandle removes "@handle" occurrences that are not +// prefixes of a longer username (same boundary rule as +// mentionsMattermostHandle). Case-insensitive matching happens in the +// ORIGINAL text's coordinate system via the package-wide ASCII fold +// (#3572 lesson: never locate in ToLower and consume in the original). +func stripMattermostHandle(text, handle string) string { + needle := "@" + strings.ToLower(handle) + var b strings.Builder + for i := 0; i < len(text); { + if n, ok := matrixASCIIFoldPrefix(text[i:], needle); ok { + end := i + n + if end >= len(text) || !mattermostIsUsernameContByte(text[end]) { + i = end // skip the mention + continue + } + } + b.WriteByte(text[i]) + i++ + } + return b.String() +} + func (a *mattermostAdapter) hasMention(text string) bool { lower := strings.ToLower(text) - // Word-boundary match: bot name "al" must not fire on "@alex" (#963). + // Username-boundary match: bot name "al" must not fire on "@alex" + // (#963), while trailing "-"/"." usernames still match (#4322). for _, id := range []string{a.botUsername, a.botUserID} { if id == "" { continue } - re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(id) + `\b`) - if re.MatchString(lower) { + if mentionsMattermostHandle(lower, id) { return true } } @@ -483,14 +542,14 @@ func (a *mattermostAdapter) hasMention(text string) bool { } func (a *mattermostAdapter) stripMention(text string) string { - // Strip @bot_username and @bot_user_id mentions, with word boundary so a - // short bot name ("al") doesn't carve text out of "@alex" (#963). - for _, pattern := range []string{a.botUsername, a.botUserID} { - if pattern == "" { + // Strip @bot_username and @bot_user_id mentions, with a username + // boundary so a short bot name ("al") doesn't carve text out of + // "@alex" (#963) while non-word endings "\b" missed still strip (#4322). + for _, handle := range []string{a.botUsername, a.botUserID} { + if handle == "" { continue } - re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(pattern) + `\b`) - text = re.ReplaceAllString(text, "") + text = stripMattermostHandle(text, handle) } return strings.TrimSpace(text) } diff --git a/internal/im/zz_issue4322_mattermost_mention_test.go b/internal/im/zz_issue4322_mattermost_mention_test.go new file mode 100644 index 000000000..a1a62acc7 --- /dev/null +++ b/internal/im/zz_issue4322_mattermost_mention_test.go @@ -0,0 +1,81 @@ +package im + +import "testing" + +// #4322: RE2's \b never matches after a username ending in "-" or "." (both +// legal per Mattermost's ^[a-z0-9.\-_]+$ charset), so hasMention/stripMention +// silently dropped those mentions. These tests pin the post-match boundary +// fix and the #963 short-name guard it must preserve. + +func TestIssue4322HasMentionTrailingDashOrDot(t *testing.T) { + a := &mattermostAdapter{botUsername: "dev-", botUserID: "abc123"} + + cases := []struct { + name string + text string + want bool + }{ + {"trailing dash username", "@dev- 帮我看下日志", true}, + {"trailing dash at end of text", "@dev-", true}, + {"uppercase mention", "@DEV- x", true}, + {"trailing dot is not our bot", "@ops1. hello", false}, // "ops1." bot covered below + {"user id mention", "@abc123 ping", true}, + {"embedded not a mention", "email dev@example.com", false}, + {"plain text", "hello world", false}, + } + + for _, c := range cases { + if got := a.hasMention(c.text); got != c.want { + t.Errorf("%s: hasMention(%q) = %v, want %v", c.name, c.text, got, c.want) + } + } + + // Dedicated trailing-dot adapter. + b := &mattermostAdapter{botUsername: "ops1."} + if !b.hasMention("@ops1. hello") { + t.Error("hasMention must match a username ending in '.'") + } + if b.hasMention("@ops1.xyz hello") { + t.Error("hasMention must NOT fire when '.' is a prefix of a longer username") + } +} + +// #963 regression: bot name "al" must not fire on "@alex". +func TestIssue4322ShortNameGuardPreserved(t *testing.T) { + a := &mattermostAdapter{botUsername: "al"} + + if a.hasMention("@alex hello") { + t.Error("short name 'al' must not match '@alex' (#963)") + } + if !a.hasMention("@al hello") { + t.Error("'@al' alone must match") + } + if a.hasMention("@alice.") { + t.Error("'@alice.' must not match bot 'al' (continuation bytes)") + } +} + +func TestIssue4322StripMention(t *testing.T) { + a := &mattermostAdapter{botUsername: "dev-", botUserID: "abc123"} + + cases := []struct { + name string + text string + want string + }{ + {"trailing dash stripped", "@dev- 帮我看下日志", "帮我看下日志"}, + {"uppercase mention stripped, body case kept", "@DEV- Keep This", "Keep This"}, + {"user id stripped", "@abc123 ping me", "ping me"}, + {"longer username not carved (#963)", "@alex is here", "@alex is here"}, + {"no mention untouched", "plain text", "plain text"}, + // Double space is pre-existing semantics (old regex left it too); + // only the mention itself is removed mid-text. + {"mid-text mention stripped", "hey @dev- check this", "hey check this"}, + } + + for _, c := range cases { + if got := a.stripMention(c.text); got != c.want { + t.Errorf("%s: stripMention(%q) = %q, want %q", c.name, c.text, got, c.want) + } + } +}