mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-17 15:19:00 +02:00
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>
251 lines
8.2 KiB
Go
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())
|
|
}
|
|
}
|