mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* feat(server): let agents steer running turns they could start (MUL-8285) An agent comment can now be added to another agent's running turn, the way a member steers from the composer: exactly when the comment could start that agent (canInvokeAgent, judged on the human at the top of the chain), never its own turn. - steer_task_ids now work for agent authors; steer: true asks the server to add the comment to the current running turn of every recipient without a named turn. - Agent-authored bindings are capped per turn (10) and per authoring agent (3). The task row is locked in its own statement before the bind, so the cap count sees what an earlier lock holder committed. - An agent comment that steered a turn but was never read is replayed on completion like the mention it replaced. Member receipts keep today's behaviour (the member sees the failure and can retry or resend). - The supplement claim returns author_type and the agent's name. A daemon that does not send task-supplement-author-v1 gets the attribution in the content, so an agent message never reads as a human's. - can_supplement on task-runs is computed for an agent caller by the same rule. Co-authored-by: Tim Smart <[email protected]> Co-authored-by: WitMiao <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(daemon): present an agent's added message as an agent's (MUL-8285) Claims send the task-supplement-author-v1 capability and render agent messages as peer input that cannot replace the objective or override humans. Co-authored-by: Tim Smart <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(cli): add --steer and --steer-active to issue comment add (MUL-8285) Docs: CLI help, CLI_AND_DAEMON.md, the CLI docs in every locale, the squad leader briefing, and a steering section in the multica-platform mentions reference. Co-authored-by: Tim Smart <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix: close the review gaps in agent steering (MUL-8285) - Replay an unread agent steer even when it reached the turn through an implicit route (a worker's reply to its leader): a comment bound to the completing run counts as planned input for the replay filter. - Drop the receipt of an agent steer once completion has replayed it, so no client (old or new) offers to resend what the follow-up already handles. - Never hand an agent's message to a daemon without task-supplement-author-v1: it would frame it as a human's. The message stays pending, settles when the turn ends, and is replayed as a mention. - Tell a squad leader about --steer only when its daemon advertises cli-comment-steer-v1, since an older CLI rejects the flag; the skill reference says to drop the flag on 'unknown flag'. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Tim Smart <[email protected]> Co-authored-by: WitMiao <[email protected]> Co-authored-by: multica-agent <[email protected]>
118 lines
3.9 KiB
Go
118 lines
3.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Helper to build a pgtype.UUID from a string.
|
|
func testUUID(s string) pgtype.UUID {
|
|
return parseUUID(s)
|
|
}
|
|
|
|
// Helper to build a pgtype.Text.
|
|
func testText(s string) pgtype.Text {
|
|
return pgtype.Text{String: s, Valid: true}
|
|
}
|
|
|
|
const (
|
|
agentAssigneeID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
otherAgentID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|
memberID = "cccccccc-cccc-cccc-cccc-cccccccccccc"
|
|
otherMemberID = "dddddddd-dddd-dddd-dddd-dddddddddddd"
|
|
)
|
|
|
|
func issueWithAgentAssignee() db.Issue {
|
|
return db.Issue{
|
|
AssigneeType: testText("agent"),
|
|
AssigneeID: testUUID(agentAssigneeID),
|
|
}
|
|
}
|
|
|
|
func issueNoAssignee() db.Issue {
|
|
return db.Issue{}
|
|
}
|
|
|
|
func TestHasAgentOrSquadMention(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
want bool
|
|
}{
|
|
{"plain", "just a plain comment", false},
|
|
{"agent", fmt.Sprintf("[@Agent](mention://agent/%s) please fix", agentAssigneeID), true},
|
|
{"squad", "[@Squad](mention://squad/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) please coordinate", true},
|
|
{"member only", fmt.Sprintf("[@Bob](mention://member/%s) take a look", memberID), false},
|
|
{"issue only", "[PAN-1](mention://issue/44c266e7-f6dd-4be3-9140-5ac40233f79c) is related", false},
|
|
{"all only", "[@all](mention://all/all) heads up", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := hasAgentOrSquadMention(parseMentionsForTest(tt.content))
|
|
if got != tt.want {
|
|
t.Errorf("hasAgentOrSquadMention() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func parseMentionsForTest(content string) []util.Mention {
|
|
return util.ParseMentions(content)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 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 the cascade tries to resolve mentions, parent ownership, or assignee
|
|
// fallback. A nil-Queries Handler would panic if the /note guard were missing or
|
|
// moved below those branches.
|
|
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, memberID, nil, commentSteerRequest{})
|
|
}
|