Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions cmd/ggcode/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
79 changes: 69 additions & 10 deletions internal/im/mattermost_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"net/http"
"net/textproto"
"os"
"regexp"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -467,30 +469,87 @@ 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
}
}
return false
}

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)
}
Expand Down
81 changes: 81 additions & 0 deletions internal/im/zz_issue4322_mattermost_mention_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading