Files
multica/server/internal/handler/chat_history_test.go
Bohan Jiang 3a6d3522c8 feat(slack): two-command channel reads — chat history (overview) + chat thread [id] (MUL-3871) (#4762)
Replaces the single scoped `multica chat history --scope` read with two clean
noun-commands so the agent can navigate a channel with many threads (e.g. read
the specific thread a user referred to):

- `multica chat history` — the channel OVERVIEW: recent top-level messages, each
  thread tagged with thread_id + reply_count + latest_reply (it does NOT expand
  thread contents). Backed by GET /api/chat/history + slack.History.ChannelOverview
  (conversations.history).
- `multica chat thread [id]` — read one thread: no id = the thread you're in,
  an id = a specific thread IN THE SAME channel. Backed by GET /api/chat/thread +
  slack.History.Thread (conversations.replies; DM falls back to history).

The channel stays server-pinned to the session; a thread id is only a
within-channel locator, so the security boundary (no cross-channel reads) is
unchanged. `--scope` is removed.

The prompt now teaches both commands and, via a new chat_in_thread signal
(derived from the binding: last_thread_id != last_message_id), tells the agent
which to start with — `chat history` for a top-level @mention, `chat thread` for
an in-thread one.

Tests: slack ChannelOverview/Thread (current/by-id/DM-fallback/no-binding/clamp),
handler both endpoints + auth, prompt top-level vs in-thread guidance.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 12:46:47 +08:00

251 lines
8.2 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel"
"github.com/multica-ai/multica/server/internal/integrations/slack"
)
type fakeChatHistoryReader struct {
page channel.HistoryPage
err error
overviewCalls int
threadCalls int
gotSession pgtype.UUID
gotThreadID string
gotOpts channel.HistoryOptions
}
func (f *fakeChatHistoryReader) ChannelOverview(_ context.Context, sid pgtype.UUID, opts channel.HistoryOptions) (channel.HistoryPage, error) {
f.overviewCalls++
f.gotSession = sid
f.gotOpts = opts
return f.page, f.err
}
func (f *fakeChatHistoryReader) Thread(_ context.Context, sid pgtype.UUID, threadID string, opts channel.HistoryOptions) (channel.HistoryPage, error) {
f.threadCalls++
f.gotSession = sid
f.gotThreadID = threadID
f.gotOpts = opts
return f.page, f.err
}
// newChatHistoryTask inserts a chat task bound to a fresh chat session and
// returns the task id. With chatSession=false it inserts a non-chat task.
func newChatHistoryTask(t *testing.T, chatSession bool) string {
t.Helper()
agentID := createHandlerTestAgent(t, "ChatHistoryAgent", []byte("[]"))
runtimeID := handlerTestRuntimeID(t)
var sessionArg any
if chatSession {
sessionArg = createHandlerTestChatSession(t, agentID)
}
var taskID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, chat_session_id)
VALUES ($1, $2, 'completed', 0, $3)
RETURNING id
`, agentID, runtimeID, sessionArg).Scan(&taskID); err != nil {
t.Fatalf("insert chat history task: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
})
return taskID
}
// taskActorReq builds a request as the Auth middleware would leave it for a mat_
// task token: the server-set X-Actor-Source=task_token + the authoritative
// X-Task-ID. target may carry query params (e.g. "?id=70.0").
func taskActorReq(target, taskID string) *http.Request {
req := newRequest("GET", target, nil)
req.Header.Set("X-Actor-Source", "task_token")
req.Header.Set("X-Task-ID", taskID)
return req
}
func withSlackHistory(t *testing.T, r ChatChannelHistoryReader) {
t.Helper()
orig := testHandler.SlackHistory
testHandler.SlackHistory = r
t.Cleanup(func() { testHandler.SlackHistory = orig })
}
func TestGetChatChannelHistory_Success(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID := newChatHistoryTask(t, true)
fake := &fakeChatHistoryReader{page: channel.HistoryPage{
ChannelType: "slack",
Messages: []channel.HistoryMessage{
{ID: "100", Author: "Alice", Role: channel.HistoryRoleUser, Text: "deploy thread", TS: "100", ThreadID: "100", ReplyCount: 3},
{ID: "101", Author: "Bob", Role: channel.HistoryRoleUser, Text: "fyi", TS: "101"},
},
NextCursor: "100",
}}
withSlackHistory(t, fake)
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, taskActorReq("/api/chat/history?limit=10", taskID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var resp ChatChannelHistoryResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.ChannelType != "slack" || len(resp.Messages) != 2 || resp.NextCursor != "100" {
t.Fatalf("unexpected response: %+v", resp)
}
if resp.Messages[0].ThreadID != "100" || resp.Messages[0].ReplyCount != 3 {
t.Errorf("overview did not carry thread metadata: %+v", resp.Messages[0])
}
if fake.overviewCalls != 1 || fake.threadCalls != 0 {
t.Errorf("expected ChannelOverview, got overview=%d thread=%d", fake.overviewCalls, fake.threadCalls)
}
}
func TestGetChatThread_CurrentThread(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID := newChatHistoryTask(t, true)
fake := &fakeChatHistoryReader{page: channel.HistoryPage{ChannelType: "slack", ThreadID: "50.0", Messages: []channel.HistoryMessage{{ID: "50.0", TS: "50.0", Text: "root"}}}}
withSlackHistory(t, fake)
w := httptest.NewRecorder()
testHandler.GetChatThread(w, taskActorReq("/api/chat/thread", taskID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
if fake.threadCalls != 1 || fake.overviewCalls != 0 {
t.Errorf("expected Thread, got overview=%d thread=%d", fake.overviewCalls, fake.threadCalls)
}
if fake.gotThreadID != "" {
t.Errorf("current-thread read should pass empty id, got %q", fake.gotThreadID)
}
}
func TestGetChatThread_ByID(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID := newChatHistoryTask(t, true)
fake := &fakeChatHistoryReader{page: channel.HistoryPage{ChannelType: "slack", ThreadID: "70.0"}}
withSlackHistory(t, fake)
w := httptest.NewRecorder()
testHandler.GetChatThread(w, taskActorReq("/api/chat/thread?id=70.0", taskID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
if fake.gotThreadID != "70.0" {
t.Errorf("thread id passed to reader = %q, want 70.0", fake.gotThreadID)
}
}
func TestGetChatHistory_NoBindingReturnsNote(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID := newChatHistoryTask(t, true)
withSlackHistory(t, &fakeChatHistoryReader{err: slack.ErrNoSlackSession})
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, taskActorReq("/api/chat/history", taskID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var resp ChatChannelHistoryResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Note == "" || len(resp.Messages) != 0 {
t.Fatalf("expected empty messages + a note, got %+v", resp)
}
}
func TestGetChatHistory_NilReaderReturnsNote(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID := newChatHistoryTask(t, true)
withSlackHistory(t, nil)
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, taskActorReq("/api/chat/history", taskID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var resp ChatChannelHistoryResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Note == "" {
t.Fatalf("expected a note when no reader configured, got %+v", resp)
}
}
// TestGetChatHistory_RejectsForgedTaskID: a normal request (no server-set
// X-Actor-Source) that forges X-Task-ID — what a member could do with a JWT /
// mul_ PAT, since the Auth middleware does NOT strip a client-sent X-Task-ID —
// must be rejected, never served another session's history.
func TestGetChatHistory_RejectsForgedTaskID(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID := newChatHistoryTask(t, true)
fake := &fakeChatHistoryReader{page: channel.HistoryPage{ChannelType: "slack"}}
withSlackHistory(t, fake)
req := newRequest("GET", "/api/chat/history", nil)
req.Header.Set("X-Task-ID", taskID) // forged: no X-Actor-Source=task_token
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
if fake.overviewCalls != 0 || fake.threadCalls != 0 {
t.Fatalf("reader must not be called for a forged X-Task-ID")
}
}
func TestGetChatHistory_MissingTaskHeader(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
// Task-token actor source but no X-Task-ID: a defensive 400.
req := newRequest("GET", "/api/chat/history", nil)
req.Header.Set("X-Actor-Source", "task_token")
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}
func TestGetChatHistory_NonChatTask(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID := newChatHistoryTask(t, false) // task with no chat_session_id
withSlackHistory(t, &fakeChatHistoryReader{})
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, taskActorReq("/api/chat/history", taskID))
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400: %s", w.Code, w.Body.String())
}
}