Files
multica/server/internal/integrations/channel/engine/session_test.go
Bohan Jiang cb6616f530 feat(slack): Socket Mode channel.Channel adapter (MUL-3516) (#4523)
* feat(slack): Socket Mode channel.Channel adapter (MUL-3516)

First slice of the Slack adapter: implements channel.Channel (Type/Connect/Disconnect/Send/Capabilities) over Slack Socket Mode, normalizes inbound events to channel.InboundMessage (DM, channel @mention, thread reply; bot-loop + edit/delete guards), decodes the per-installation config/secret blob, and registers the Factory under TypeSlack. No engine, core, or channel_* schema change. Unit-tested (translation, capabilities, config decode, chunking, Send via httptest). Resolvers + engine wiring + Block Kit binding replier follow.

Co-authored-by: multica-agent <github@multica.ai>

* fix(slack): address adapter review (MUL-3516)

- Propagate InboundHandler errors through dispatchEventsAPI/handleSocketEvent to Connect so an infra failure tears down the connection for Supervisor reconnect/backoff instead of being silently swallowed (ACK still happens first).
- Capabilities: declare only CapText | CapThreadReply; drop CapRichCard/CapAttachment/CapMessageEdit until those Send paths are wired.
- slackChatType: map mpim (multi-party DM) to group, not p2p, so the 'must address bot' filter applies; only 1:1 im is p2p.
- Document the group-addressing decision: explicit @bot mention required in groups; mention-free thread continuation deferred to the session-aware layer.
- Tests: handler-error propagation, slackChatType table, mpim-requires-mention, capabilities negative assertions.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(channel): shared channel-agnostic ChatSession service (MUL-3516)

Extract the session/append//issue machinery — currently locked inside the Feishu-pinned lark.chatSessionService — into a shared engine.ChatSession parameterized by channel_type + session titles, so every IM adapter reuses it instead of re-implementing it. Logic is verbatim (find-or-create session+binding with unique-violation race re-read; append+touch+reply-target+in-tx dedup Mark; /issue parse with bare-command previous-message fallback) but channel-neutral: command-parse source is supplied by the adapter (enrichment is platform-specific). Backed by a narrow SessionQueries interface so it is unit-tested with an in-memory fake (no DB). /issue parser moved to engine.ParseIssueCommand. Next: migrate Feishu onto it and wire Slack's ResolverSet, removing the lark duplicate.

Co-authored-by: multica-agent <github@multica.ai>

* fix(channel): decouple session binding key from outbound target (MUL-3516)

Addresses Elon's round-2 review. engine.ChatSession.EnsureSession previously keyed the binding on a raw chat id (EnsureSessionInput.ChatID), so a resolver wiring Slack straight through would collapse every @bot thread in one channel into a single chat_session and overwrite last_thread_id. Make the API un-misusable:

- EnsureSessionInput.ChatID -> BindingKey: the explicit session-isolation key (Feishu: chat id; Slack DM: channel id; Slack channel: channel id + thread root), documented so a raw threaded-platform chat id is never passed straight through.
- Add EnsureSessionInput.BindingConfig (opaque) persisted on the binding's config column, so the real outbound channel/thread is preserved when BindingKey is composite — outbound routing stays separate from the isolation key.
- channel.sql CreateChannelChatSessionBinding now writes config (additive, uses the existing NOT NULL column; lark caller passes '{}', no schema change, no Feishu regression).
- Tests: TestEnsureSession_ThreadRootIsolation (two thread roots in one channel -> two sessions; same root reuses) and TestEnsureSession_StoresBindingConfig.

No production wiring change yet (per review, the not-yet-wired shared service is an accepted preparatory state); this makes the API correct before Feishu/Slack are migrated onto it.

Co-authored-by: multica-agent <github@multica.ai>

* feat(slack): Slack ResolverSet with thread-root session isolation (MUL-3516)

Wires Slack into the channel-agnostic engine.Router via a ResolverSet built on the generic channel_* queries (installation route by team_id, identity + workspace-membership recheck, two-phase dedup, audit) plus the shared engine.ChatSession. No new query, no schema change.

slackSessionRouting is the per-message isolation rule (Elon round-2 / Niko round-3): a DM is one session per channel; a channel/group message is isolated by thread root (key = channel:threadRoot, root = inbound thread_ts or the message ts for a top-level @mention), so two @bot threads in one channel are two sessions. The real channel id rides in BindingConfig for outbound; the reply thread is returned separately. Tests cover DM/channel/thread routing, config, and that distinct thread roots isolate while a same-thread follow-up reuses its key.

Not yet wired into router.go (still a preparatory commit, per review); Feishu migration onto the shared service, router/config wiring, and the Slack outbound path follow.

Co-authored-by: multica-agent <github@multica.ai>

* feat(slack): Markdown->mrkdwn outbound formatting (MUL-3516)

Slack renders mrkdwn, not Markdown, so an unconverted agent reply shows literal ** , ## and [text](url). Add formatMrkdwn — a faithful Go port of Hermes Agent's slack format_message (MIT) — and apply it in slackChannel.Send before chunking/posting. Protects fenced+inline code, converted links, and existing Slack entities behind placeholders; converts headers/bold/italic/strike/links; escapes control chars. Unit tests cover each construct plus fenced-code protection and a link nested in bold.

Co-authored-by: multica-agent <github@multica.ai>

* docs(slack): preserve Hermes MIT notice for ported mrkdwn converter (MUL-3516)

Addresses Niko's review. formatMrkdwn is a substantial port of Hermes Agent's slack format_message; MIT requires preserving the copyright + permission notice. Add the full Hermes MIT copyright/permission notice + source URL as a header on mrkdwn.go (no repo-level third-party notice file exists, and the header cannot get separated from the ported code). Also add the suggested Send-layer regression test (TestSend_AppliesMrkdwn) that pins the wiring: slackChannel.Send converts Markdown to mrkdwn before posting.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(lark): migrate Feishu onto shared engine.ChatSession, drop duplicate (MUL-3516)

Completes 'every IM reuses one shared session service' and removes the dual-path the reviewers flagged as temporary. Feishu's ResolverSet now drives the channel-agnostic engine.ChatSession (channel_type=feishu, Lark session titles preserved) instead of the Feishu-specific lark.chatSessionService, which is deleted. Behavior is unchanged: engine.ChatSession is the verbatim port of the old logic and is unit-tested; the new Feishu binder param-mapping (BindingKey=chat id, CommandText=un-enriched CommandBody from Raw) is covered by feishu_resolvers_test.go.

- Delete chat_service.go (chatSessionService + helpers) and issue_command.go/_test.go (parser now engine.ParseIssueCommand). Relocate the shared TxStarter interface to tx.go (still used by binding-token + registration services).
- chat.go keeps only the AuditLogger seam; remove the now-dead ChatSessionService / EnsureChatSessionParams / AppendUserMessageParams / AppendResult / IssueCommand types.
- router.go constructs engine.NewChatSession for Feishu; inbound_enricher_test + doc.go updated.

make-test parity: go build ./..., go vet, gofmt, and go test ./internal/integrations/{lark,channel/...,slack} all pass (full Feishu suite green).

Co-authored-by: multica-agent <github@multica.ai>

* feat(slack): wire Slack adapter + ResolverSet + outbound into router (MUL-3516)

Activates the full Slack pipeline, gated by MULTICA_SLACK_SECRET_KEY (the bot/app-token decryption key). When unset the block is skipped, so existing deployments are unaffected and Feishu is untouched.

- router.go registers slack.RegisterSlack (Socket Mode connect/send Factory) + channelRouter.Register(TypeSlack, NewSlackResolverSet) (inbound pipeline) + slack.NewOutbound(...).Register(bus) (outbound).
- New slack/outbound.go: an EventChatDone subscriber mirroring the Feishu Patcher. It finds the Slack chat binding for the finished session, recovers the real channel from the binding config (the channel_chat_id may be a composite thread-isolation key) + the reply thread from last_thread_id, and posts via slackChannel.Send (reusing formatMrkdwn / chunking / threading). Sessions with no Slack binding are ignored, so it coexists with the Feishu Patcher on the shared bus.
- Tests: posts to the bound channel/thread with the real channel id; ignores non-Slack sessions, empty completions, revoked installations, and non-chat events.

Slack now shares engine.ChatSession, channel_* tables, IssueService and TaskService with Feishu. Remaining: config-driven installation provisioning (an operator currently creates the channel_type='slack' row; the config block shape — which workspace/agent — is a product decision) and a live end-to-end smoke. go build ./..., go vet, gofmt, and go test ./internal/integrations/{slack,channel/...,lark} all pass.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 14:29:00 +08:00

323 lines
11 KiB
Go

package engine
import (
"context"
"fmt"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// uid builds a deterministic, valid pgtype.UUID from a single byte so tests can
// compare ids by equality.
func uid(b byte) pgtype.UUID {
var u pgtype.UUID
u.Bytes[0] = b
u.Valid = true
return u
}
// fakeTx satisfies pgx.Tx by embedding the (nil) interface; the ChatSession
// service only calls Commit/Rollback, which we override as no-ops.
type fakeTx struct{ pgx.Tx }
func (fakeTx) Commit(context.Context) error { return nil }
func (fakeTx) Rollback(context.Context) error { return nil }
type fakeTxStarter struct{}
func (fakeTxStarter) Begin(context.Context) (pgx.Tx, error) { return fakeTx{}, nil }
// fakeSessionQueries is an in-memory SessionQueries for unit tests.
type fakeSessionQueries struct {
bindings map[string]pgtype.UUID
nextSession byte
createdSessions int
messages []string
touched int
replyTargets int
lastConfig []byte // config of the most recent CreateChannelChatSessionBinding
prevMessage *string // GetMostRecentUserChatMessage result; nil → ErrNoRows
markRows int64 // MarkChannelInboundDedupProcessed result
createBindingErr error // simulate a unique violation on create
raceWinner pgtype.UUID
}
func newFake() *fakeSessionQueries {
return &fakeSessionQueries{bindings: map[string]pgtype.UUID{}, markRows: 1}
}
func bindKey(inst pgtype.UUID, chat string) string { return fmt.Sprintf("%x|%s", inst.Bytes, chat) }
func (f *fakeSessionQueries) WithTx(tx pgx.Tx) SessionQueries { return f }
func (f *fakeSessionQueries) GetChannelChatSessionBinding(_ context.Context, arg db.GetChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error) {
if id, ok := f.bindings[bindKey(arg.InstallationID, arg.ChannelChatID)]; ok {
return db.ChannelChatSessionBinding{ChatSessionID: id}, nil
}
return db.ChannelChatSessionBinding{}, pgx.ErrNoRows
}
func (f *fakeSessionQueries) CreateChatSession(_ context.Context, _ db.CreateChatSessionParams) (db.ChatSession, error) {
f.nextSession++
f.createdSessions++
return db.ChatSession{ID: uid(f.nextSession)}, nil
}
func (f *fakeSessionQueries) CreateChannelChatSessionBinding(_ context.Context, arg db.CreateChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error) {
f.lastConfig = arg.Config
if f.createBindingErr != nil {
// Simulate the race winner having committed its binding first.
f.bindings[bindKey(arg.InstallationID, arg.ChannelChatID)] = f.raceWinner
return db.ChannelChatSessionBinding{}, f.createBindingErr
}
f.bindings[bindKey(arg.InstallationID, arg.ChannelChatID)] = arg.ChatSessionID
return db.ChannelChatSessionBinding{ChatSessionID: arg.ChatSessionID}, nil
}
func (f *fakeSessionQueries) CreateChatMessage(_ context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error) {
f.messages = append(f.messages, arg.Content)
return db.ChatMessage{}, nil
}
func (f *fakeSessionQueries) TouchChatSession(context.Context, pgtype.UUID) error {
f.touched++
return nil
}
func (f *fakeSessionQueries) GetMostRecentUserChatMessage(context.Context, pgtype.UUID) (db.ChatMessage, error) {
if f.prevMessage != nil {
return db.ChatMessage{Content: *f.prevMessage}, nil
}
return db.ChatMessage{}, pgx.ErrNoRows
}
func (f *fakeSessionQueries) UpdateChannelChatSessionBindingReplyTarget(context.Context, db.UpdateChannelChatSessionBindingReplyTargetParams) error {
f.replyTargets++
return nil
}
func (f *fakeSessionQueries) MarkChannelInboundDedupProcessed(context.Context, db.MarkChannelInboundDedupProcessedParams) (int64, error) {
return f.markRows, nil
}
func newTestSession(f SessionQueries) *ChatSession {
return newChatSessionWith(f, fakeTxStarter{}, channel.TypeFeishu, SessionTitles{Group: "G", Direct: "D", Fallback: "F"})
}
func TestEnsureSession_CreateThenReuse(t *testing.T) {
f := newFake()
s := newTestSession(f)
in := EnsureSessionInput{InstallationID: uid(1), BindingKey: "chatA", ChatType: channel.ChatTypeP2P, Sender: uid(7)}
id1, err := s.EnsureSession(context.Background(), in)
if err != nil {
t.Fatalf("first EnsureSession: %v", err)
}
if f.createdSessions != 1 {
t.Fatalf("createdSessions = %d, want 1", f.createdSessions)
}
id2, err := s.EnsureSession(context.Background(), in)
if err != nil {
t.Fatalf("second EnsureSession: %v", err)
}
if f.createdSessions != 1 {
t.Errorf("second call must reuse the binding, not create: createdSessions = %d", f.createdSessions)
}
if id1 != id2 {
t.Errorf("ids differ: %v vs %v", id1, id2)
}
}
func TestEnsureSession_RaceUniqueViolation(t *testing.T) {
f := newFake()
f.createBindingErr = &pgconn.PgError{Code: "23505"}
f.raceWinner = uid(99)
s := newTestSession(f)
id, err := s.EnsureSession(context.Background(), EnsureSessionInput{InstallationID: uid(1), BindingKey: "chatA", ChatType: channel.ChatTypeGroup})
if err != nil {
t.Fatalf("EnsureSession on race: %v", err)
}
if id != uid(99) {
t.Errorf("lost-race re-read should return the winner's session: %v", id)
}
}
// TestEnsureSession_ThreadRootIsolation is the regression guard for Elon's
// must-fix: two @bot threads in the SAME Slack channel must NOT collapse into
// one chat_session. The Slack resolver composes BindingKey = channel + thread
// root, so distinct thread roots map to distinct sessions while a follow-up in
// the same thread reuses its session.
func TestEnsureSession_ThreadRootIsolation(t *testing.T) {
f := newFake()
s := newTestSession(f)
mk := func(key string) pgtype.UUID {
id, err := s.EnsureSession(context.Background(), EnsureSessionInput{
InstallationID: uid(1), BindingKey: key, ChatType: channel.ChatTypeGroup,
})
if err != nil {
t.Fatalf("EnsureSession(%q): %v", key, err)
}
return id
}
thread1 := mk("C123:1111.0001")
thread2 := mk("C123:2222.0002") // same channel, different thread root
if thread1 == thread2 {
t.Fatal("distinct thread roots in one channel must get distinct sessions")
}
if f.createdSessions != 2 {
t.Fatalf("createdSessions = %d, want 2", f.createdSessions)
}
again := mk("C123:1111.0001") // a follow-up in thread 1
if again != thread1 {
t.Error("same thread root must reuse its session")
}
if f.createdSessions != 2 {
t.Errorf("a thread follow-up must not create a new session: createdSessions = %d", f.createdSessions)
}
}
func TestEnsureSession_StoresBindingConfig(t *testing.T) {
f := newFake()
s := newTestSession(f)
if _, err := s.EnsureSession(context.Background(), EnsureSessionInput{
InstallationID: uid(1), BindingKey: "C123:1111.0001", ChatType: channel.ChatTypeGroup,
BindingConfig: []byte(`{"channel_id":"C123"}`),
}); err != nil {
t.Fatalf("EnsureSession: %v", err)
}
if string(f.lastConfig) != `{"channel_id":"C123"}` {
t.Errorf("opaque outbound routing must be persisted on the binding: %q", f.lastConfig)
}
// Empty BindingConfig defaults to the "{}" object (the column is NOT NULL).
f2 := newFake()
if _, err := newTestSession(f2).EnsureSession(context.Background(), EnsureSessionInput{
InstallationID: uid(1), BindingKey: "chatA", ChatType: channel.ChatTypeP2P,
}); err != nil {
t.Fatalf("EnsureSession: %v", err)
}
if string(f2.lastConfig) != "{}" {
t.Errorf("empty BindingConfig should default to {}: %q", f2.lastConfig)
}
}
func TestAppendUserMessage_PlainText(t *testing.T) {
f := newFake()
s := newTestSession(f)
res, err := s.AppendUserMessage(context.Background(), AppendInput{
SessionID: uid(1), Sender: uid(7), Body: "hello there", MessageID: "m1",
})
if err != nil {
t.Fatalf("AppendUserMessage: %v", err)
}
if res.IssueCommand != nil {
t.Errorf("plain text should not parse as /issue: %+v", res.IssueCommand)
}
if len(f.messages) != 1 || f.messages[0] != "hello there" {
t.Errorf("messages = %v", f.messages)
}
if f.touched != 1 || f.replyTargets != 1 {
t.Errorf("touched=%d replyTargets=%d, want 1/1", f.touched, f.replyTargets)
}
}
func TestAppendUserMessage_NoReplyTargetWithoutMessageID(t *testing.T) {
f := newFake()
s := newTestSession(f)
if _, err := s.AppendUserMessage(context.Background(), AppendInput{SessionID: uid(1), Body: "hi"}); err != nil {
t.Fatalf("AppendUserMessage: %v", err)
}
if f.replyTargets != 0 {
t.Errorf("no MessageID → no reply-target update, got %d", f.replyTargets)
}
}
func TestAppendUserMessage_IssueCommand(t *testing.T) {
f := newFake()
s := newTestSession(f)
res, err := s.AppendUserMessage(context.Background(), AppendInput{
SessionID: uid(1), Body: "/issue Fix bug\nsteps to repro", MessageID: "m1",
})
if err != nil {
t.Fatalf("AppendUserMessage: %v", err)
}
if res.IssueCommand == nil || res.IssueCommand.Title != "Fix bug" || res.IssueCommand.Description != "steps to repro" {
t.Errorf("IssueCommand = %+v", res.IssueCommand)
}
}
func TestAppendUserMessage_CommandTextOverridesEnrichedBody(t *testing.T) {
f := newFake()
s := newTestSession(f)
// Body is enriched (quoted context prepended) so /issue is NOT on the first
// line; CommandText carries the user's own text and must win.
res, err := s.AppendUserMessage(context.Background(), AppendInput{
SessionID: uid(1),
Body: "> quoted context from another message\n/issue Real intent",
CommandText: "/issue Real intent",
MessageID: "m1",
})
if err != nil {
t.Fatalf("AppendUserMessage: %v", err)
}
if res.IssueCommand == nil || res.IssueCommand.Title != "Real intent" {
t.Errorf("CommandText should drive /issue parsing: %+v", res.IssueCommand)
}
// The stored message is still the full (enriched) body.
if f.messages[0] != "> quoted context from another message\n/issue Real intent" {
t.Errorf("stored body should be the enriched Body: %q", f.messages[0])
}
}
func TestAppendUserMessage_BareIssueUsesPreviousMessage(t *testing.T) {
f := newFake()
prev := "Make the export button work"
f.prevMessage = &prev
s := newTestSession(f)
res, err := s.AppendUserMessage(context.Background(), AppendInput{SessionID: uid(1), Body: "/issue", MessageID: "m2"})
if err != nil {
t.Fatalf("AppendUserMessage: %v", err)
}
if res.IssueCommand == nil || res.IssueCommand.Title != "Make the export button work" {
t.Errorf("bare /issue should fall back to previous message title: %+v", res.IssueCommand)
}
}
func TestAppendUserMessage_DedupMark(t *testing.T) {
f := newFake()
f.markRows = 1
s := newTestSession(f)
res, err := s.AppendUserMessage(context.Background(), AppendInput{
SessionID: uid(1), Body: "hi", MessageID: "m1", InstallationID: uid(1), ClaimToken: uid(5),
})
if err != nil {
t.Fatalf("AppendUserMessage: %v", err)
}
if !res.DedupMarked {
t.Error("a successful in-tx Mark should set DedupMarked")
}
}
func TestAppendUserMessage_ClaimLost(t *testing.T) {
f := newFake()
f.markRows = 0 // a concurrent reclaim rotated the token
s := newTestSession(f)
_, err := s.AppendUserMessage(context.Background(), AppendInput{
SessionID: uid(1), Body: "hi", MessageID: "m1", InstallationID: uid(1), ClaimToken: uid(5),
})
if err != ErrClaimLost {
t.Errorf("zero Mark rows must return ErrClaimLost, got %v", err)
}
}