Files
multica/server/internal/integrations/slack/slash_command_test.go
Bohan Jiang 159d9bebb1 feat(slack): route /issue slash command through quick-create (MUL-3908) (#4793)
The Slack `/issue` slash command used to directly create a raw issue: the
typed line became the title verbatim and a `todo` issue was assigned to the
agent to work on immediately. That files a rough, unstructured issue and starts
the agent on it before it is well-formed.

Switch the slash command to the quick-create pipeline instead
(TaskService.EnqueueQuickCreateTask, the same path as the web "quick create"
modal): the invoker's natural-language description is handed to the
installation's agent as a prompt, and the agent authors a well-formed issue
(proper title + structured description) in the background, attributed to the
bound member. Because creation is now asynchronous, the ephemeral reply is an
acknowledgement ("On it…") rather than a created-confirmation with a number;
the agent's completion surfaces to the invoker as a Multica inbox notification
through the shared quick-create completion path.

Installation routing and identity/membership checks are unchanged, so the same
workspace boundary and account-binding rules apply. Scope is the slash command
only — the message-based `@bot /issue` still runs through the shared
cross-platform engine (which also serves Lark) and keeps its direct-create
behavior.

- slash_command.go: swap IssueService.Create for EnqueueQuickCreateTask via a
  narrow quickCreateEnqueuer interface; prompt is the full text (no title/body
  split); drop the now-unused splitIssueText / issueCreatedText / GetWorkspace.
- router.go: wire h.TaskService instead of h.IssueService.
- tests: cover enqueue + ack, multiline prompt pass-through, empty prompt,
  unbound, non-member, inactive, team mismatch, and enqueue-failure.
- docs (4 locales): describe the quick-create behavior.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 17:32:42 +08:00

288 lines
8.4 KiB
Go

package slack
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/slack-go/slack"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// ---- fakes ----
type fakeSlashQueries struct {
inst db.ChannelInstallation
instErr error
binding db.ChannelUserBinding
bindErr error
memberErr error
gotAppID string
}
func (f *fakeSlashQueries) GetChannelInstallationByAppID(_ context.Context, arg db.GetChannelInstallationByAppIDParams) (db.ChannelInstallation, error) {
f.gotAppID = arg.AppID
return f.inst, f.instErr
}
func (f *fakeSlashQueries) GetChannelUserBindingByUserID(_ context.Context, _ db.GetChannelUserBindingByUserIDParams) (db.ChannelUserBinding, error) {
return f.binding, f.bindErr
}
func (f *fakeSlashQueries) GetMemberByUserAndWorkspace(_ context.Context, _ db.GetMemberByUserAndWorkspaceParams) (db.Member, error) {
return db.Member{}, f.memberErr
}
// fakeQuickCreate records the last EnqueueQuickCreateTask call so tests can
// assert the prompt is passed through verbatim and attributed correctly.
type fakeQuickCreate struct {
task db.AgentTaskQueue
err error
calls int
workspaceID pgtype.UUID
requesterID pgtype.UUID
agentID pgtype.UUID
squadID pgtype.UUID
prompt string
}
func (f *fakeQuickCreate) EnqueueQuickCreateTask(_ context.Context, workspaceID, requesterID, agentID, squadID pgtype.UUID, prompt string, _, _ pgtype.UUID, _ []pgtype.UUID) (db.AgentTaskQueue, error) {
f.calls++
f.workspaceID = workspaceID
f.requesterID = requesterID
f.agentID = agentID
f.squadID = squadID
f.prompt = prompt
return f.task, f.err
}
func slashTestUUID(b byte) pgtype.UUID {
var u pgtype.UUID
for i := range u.Bytes {
u.Bytes[i] = b
}
u.Valid = true
return u
}
// newTestSlashProcessor builds a processor over fakes and returns it plus a
// pointer to the last ephemeral reply text and the reply count.
func newTestSlashProcessor(q slashQueries, tasks quickCreateEnqueuer, binding bindingMinter) (*SlashCommandProcessor, *string, *int) {
captured := new(string)
count := new(int)
p := &SlashCommandProcessor{
q: q,
tasks: tasks,
binding: binding,
appURL: "https://app.example",
bindingPath: "/slack/bind",
logger: slog.Default(),
}
p.respond = func(_ context.Context, _ string, text string) error {
*count++
*captured = text
return nil
}
return p, captured, count
}
func activeSlashInstallation() db.ChannelInstallation {
return db.ChannelInstallation{
ID: slashTestUUID(1),
WorkspaceID: slashTestUUID(2),
AgentID: slashTestUUID(3),
InstallerUserID: slashTestUUID(4),
Status: "active",
Config: []byte(`{"app_id":"A1","team_id":"T1"}`),
}
}
func issueSlashCmd() slack.SlashCommand {
return slack.SlashCommand{
Command: "/issue",
Text: "Fix login",
APIAppID: "A1",
TeamID: "T1",
UserID: "U1",
ChannelID: "C1",
ResponseURL: "https://hooks.slack.test/response",
}
}
// ---- tests ----
func TestSlashHandle_EnqueuesQuickCreateAndAcks(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
}
tasks := &fakeQuickCreate{}
p, captured, count := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 1 {
t.Fatalf("expected 1 quick-create enqueue, got %d", tasks.calls)
}
if *count != 1 {
t.Fatalf("expected 1 ephemeral reply, got %d", *count)
}
if *captured != slashQueuedText {
t.Fatalf("expected queued ack, got %q", *captured)
}
if q.gotAppID != "A1" {
t.Errorf("installation lookup used app id %q, want A1", q.gotAppID)
}
if tasks.prompt != "Fix login" {
t.Errorf("quick-create prompt = %q, want Fix login", tasks.prompt)
}
if tasks.workspaceID != slashTestUUID(2) {
t.Errorf("quick-create workspace is not the installation workspace")
}
if tasks.agentID != slashTestUUID(3) {
t.Errorf("quick-create not dispatched to the installation agent")
}
if tasks.requesterID != slashTestUUID(9) {
t.Errorf("quick-create requester is not the bound member")
}
if tasks.squadID.Valid {
t.Errorf("slash-command quick-create must not carry a squad id")
}
}
func TestSlashHandle_MultilinePromptPassedThrough(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
}
tasks := &fakeQuickCreate{}
p, _, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.Text = " Title\nline one\nline two "
p.Handle(context.Background(), cmd)
// The whole (trimmed) natural-language text is the prompt — no title/body
// split; the agent authors the well-formed issue from it.
if tasks.prompt != "Title\nline one\nline two" {
t.Errorf("prompt = %q, want the full trimmed text", tasks.prompt)
}
}
func TestSlashHandle_EmptyPromptIsUsage(t *testing.T) {
tasks := &fakeQuickCreate{}
p, captured, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.Text = " "
p.Handle(context.Background(), cmd)
if tasks.calls != 0 {
t.Fatalf("empty prompt must not enqueue a task")
}
if *count != 1 || *captured != slashUsageText {
t.Fatalf("expected usage reply, got %q", *captured)
}
}
func TestSlashHandle_UnboundUserGetsLink(t *testing.T) {
q := &fakeSlashQueries{inst: activeSlashInstallation(), bindErr: pgx.ErrNoRows}
tasks := &fakeQuickCreate{}
bind := &fakeBindingMinter{raw: "TOKEN123"}
p, captured, _ := newTestSlashProcessor(q, tasks, bind)
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 0 {
t.Fatalf("unbound user must not enqueue a task")
}
if bind.calls != 1 {
t.Fatalf("expected a binding token to be minted, got %d", bind.calls)
}
if !strings.Contains(*captured, "link your account") || !strings.Contains(*captured, "TOKEN123") {
t.Fatalf("reply missing bind link: %q", *captured)
}
}
func TestSlashHandle_NonMemberDropped(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
memberErr: pgx.ErrNoRows,
}
tasks := &fakeQuickCreate{}
p, captured, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 0 {
t.Fatalf("non-member must not enqueue a task")
}
if *captured != slashNotMemberText {
t.Fatalf("expected not-member reply, got %q", *captured)
}
}
func TestSlashHandle_InactiveInstallation(t *testing.T) {
inst := activeSlashInstallation()
inst.Status = "revoked"
tasks := &fakeQuickCreate{}
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: inst}, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 0 || *captured != slashDisabledText {
t.Fatalf("inactive install: calls=%d reply=%q", tasks.calls, *captured)
}
}
func TestSlashHandle_TeamMismatchTreatedAsDisconnected(t *testing.T) {
tasks := &fakeQuickCreate{}
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.TeamID = "T2" // config team is T1
p.Handle(context.Background(), cmd)
if tasks.calls != 0 || *captured != slashDisabledText {
t.Fatalf("team mismatch: calls=%d reply=%q", tasks.calls, *captured)
}
}
func TestSlashHandle_EnqueueFailureIsInternalError(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
}
tasks := &fakeQuickCreate{err: errors.New("agent has no runtime")}
p, captured, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 1 {
t.Fatalf("expected the enqueue to be attempted once, got %d", tasks.calls)
}
if *captured != slashInternalErrorText {
t.Fatalf("expected internal-error reply, got %q", *captured)
}
}
func TestSlashHandle_IgnoresOtherCommands(t *testing.T) {
tasks := &fakeQuickCreate{}
p, _, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.Command = "/other"
p.Handle(context.Background(), cmd)
if tasks.calls != 0 || *count != 0 {
t.Fatalf("non-/issue command must be ignored: calls=%d replies=%d", tasks.calls, *count)
}
}