mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* feat(comments): skip agent triggering on /note-prefixed comments A comment whose first token is the reserved /note prefix (case-insensitive) is stored like any other comment but never wakes an agent. The guard sits at the top of triggerTasksForComment, the single chokepoint, so it covers all three trigger paths — assignee, squad leader, and @mentioned agents. Gating only shouldEnqueueOnComment (as originally proposed) would still let "/note @agent ..." through the mention path. Lets members leave human-only tips/notes on agent-assigned issues without burning an agent run. MUL-3115, closes #3649. Co-authored-by: multica-agent <github@multica.ai> * feat(editor): add /note built-in slash command to comment composer Enable the `/` menu in the issue comment and reply composers in a new "command" mode that lists fixed built-in commands instead of the chat skill picker. Currently one command, /note, which marks a comment as a human-only note that won't trigger the assigned agent. Selecting it inserts the plain-text "/note " prefix (not a rich node), so a menu pick and a hand-typed command are byte-identical and the backend detects either with a simple prefix match. The command menu renders nothing on a non-matching `/` (hideOnEmpty) so typing a date like 6/8 isn't noisy. The chat skill picker is unchanged. MUL-3115. Co-authored-by: multica-agent <github@multica.ai> * refactor(editor): match /note by label prefix and localize its description Address PR review feedback: - buildBuiltinCommandItems now matches the command label as a prefix only, dropping the description substring match copied from the skill picker. With one command this keeps the menu predictable (/no surfaces note; /deploy or a description word like /agent shows nothing) and avoids Enter selecting note unexpectedly. - The command description is now a localized UI string: added slash_command.commands.note to all four editor locales (en/ja/ko/zh-Hans) and the menu renders it via the typed translator. The /label itself stays literal since it's the typed token the backend matches. MUL-3115. Co-authored-by: multica-agent <github@multica.ai> * fix(editor): shorten /note command description to avoid truncation The slash menu item is single-line (truncate, w-72), so the longer copy was cut off. Shorten to "won't trigger any agents" across all four locales — also more accurate, since /note skips assignee, squad leader, and @mentioned agents, not just the assigned one. MUL-3115. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -6,7 +6,9 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -928,7 +930,35 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// noteCommentPrefix marks a comment as a human-only note. A comment whose first
|
||||
// whitespace-delimited token is this prefix (case-insensitive) is stored like
|
||||
// any other comment but never triggers an agent — see triggerTasksForComment.
|
||||
const noteCommentPrefix = "/note"
|
||||
|
||||
// isNoteComment reports whether content opts out of agent triggering via the
|
||||
// reserved /note prefix. The prefix must be the comment's first token, so
|
||||
// "/note check expiry", " /NOTE", and "/note" all match, while "/notes",
|
||||
// "/ note", and "see foo/note" do not.
|
||||
func isNoteComment(content string) bool {
|
||||
trimmed := strings.TrimLeft(content, " \t\r\n")
|
||||
firstToken := trimmed
|
||||
if i := strings.IndexFunc(trimmed, unicode.IsSpace); i >= 0 {
|
||||
firstToken = trimmed[:i]
|
||||
}
|
||||
return strings.EqualFold(firstToken, noteCommentPrefix)
|
||||
}
|
||||
|
||||
func (h *Handler) triggerTasksForComment(ctx context.Context, issue db.Issue, comment db.Comment, parentComment *db.Comment, actorType, actorID string) {
|
||||
// A comment opening with the reserved /note prefix is a human-only note: it
|
||||
// is recorded like any other comment but must not wake ANY agent. This guard
|
||||
// lives at the single chokepoint so it covers all three trigger paths below
|
||||
// (assignee, squad leader, and @mentioned agents). Gating only
|
||||
// shouldEnqueueOnComment would still let "/note @agent ..." reach an agent
|
||||
// through the mention path.
|
||||
if isNoteComment(comment.Content) {
|
||||
return
|
||||
}
|
||||
|
||||
if actorType == "member" && h.shouldEnqueueOnComment(ctx, issue, actorType, actorID) &&
|
||||
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
|
||||
!h.isReplyToMemberThread(ctx, parentComment, comment.Content, issue) {
|
||||
|
||||
@@ -315,3 +315,55 @@ func TestOnCommentTriggerDecision(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// isNoteComment — the /note opt-out prefix
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func TestIsNoteComment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want bool
|
||||
}{
|
||||
{"plain comment triggers", "just a plain comment", false},
|
||||
{"note prefix skips", "/note check the API expiry", true},
|
||||
{"bare note skips", "/note", true},
|
||||
{"uppercase note skips (case-insensitive)", "/NOTE shout", true},
|
||||
{"mixed case note skips", "/Note mixed", true},
|
||||
{"leading whitespace tolerated", " /note leading space", true},
|
||||
{"note followed by newline skips", "/note\nmultiline body", true},
|
||||
{"plural notes does not match (word boundary)", "/notes are plural", false},
|
||||
{"noteworthy does not match", "/noteworthy idea", false},
|
||||
{"slash space note does not match", "/ note has a space", false},
|
||||
{"mid-sentence note does not match", "see foo/note here", false},
|
||||
{"note as second token does not match", "fyi /note", false},
|
||||
{"empty content does not match", "", false},
|
||||
{"whitespace-only content does not match", " ", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isNoteComment(tt.content); got != tt.want {
|
||||
t.Errorf("isNoteComment(%q) = %v, want %v", tt.content, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTriggerTasksForComment_NoteShortCircuits proves a /note comment returns
|
||||
// before any of the three enqueue paths run. shouldEnqueueOnComment,
|
||||
// shouldEnqueueSquadLeaderOnComment, and enqueueMentionedAgentTasks all
|
||||
// dereference h.Queries, so a nil-Queries Handler would panic if the /note
|
||||
// guard were missing or moved below them. The comment also @mentions an agent
|
||||
// to exercise the mention path specifically.
|
||||
func TestTriggerTasksForComment_NoteShortCircuits(t *testing.T) {
|
||||
h := &Handler{} // nil Queries / TaskService on purpose
|
||||
issue := issueWithAgentAssignee()
|
||||
comment := db.Comment{
|
||||
Content: fmt.Sprintf("/note cc [@Other](mention://agent/%s) just an fyi", otherAgentID),
|
||||
}
|
||||
|
||||
// Must not panic — the guard short-circuits before any DB access.
|
||||
h.triggerTasksForComment(context.Background(), issue, comment, nil, "member", memberID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user