mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) Replace the floating chat FAB/window with a first-class Chat tab under Inbox, laid out as an IM-style two-pane surface (thread list + conversation). Highlights: - New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable session selection; web + desktop routing wired up. Removes the old chat-fab / chat-window / resize-handles / context-items paths. - IM thread list: agent avatar + last-message preview + IM timestamp, red unread *count* badge (read-cursor model), presence-gated typing vs waiting. Rename lives only in the conversation header ⋯ menu (not the list hover). - Per-session conversation header (rename / view agent / delete), agent-aware empty state (avatar + name + description + starter prompts), and a deterministic clean-title derivation from the first message. - Server: read-cursor unread model (migration 145) and per-user pinned agents (migration 146, dedicated chat_pinned_agent table + handler/queries). New-agent welcome chat auto-enqueues a real agent run (LLM intro, no static template). - Design: fade the global --border token; borderless list headers on Chat/Inbox, kept (faded) on the conversation header. Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt. Co-authored-by: multica-agent <github@multica.ai> * feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230) The "meet your new agent" chat used to insert a fake user message ("👋 Hi! Please introduce yourself …") and have the agent reply to it, so the thread looked like the creator prompting the agent. Drop the persisted user message. Flag the auto-created session is_agent_intro (migration 147) and drive the intro run server-side: the daemon builds a proactive self-introduction prompt for such sessions (buildChatPrompt) instead of a "reply to their message" prompt. The intro stays LLM-generated; the thread now opens with the agent's own message, as if it reached out first. - migration 147: chat_session.is_agent_intro - CreateChatSession carries the flag; sendAgentWelcomeChat no longer persists/publishes a user message - daemon: ChatIntro threaded from session flag → intro prompt Co-authored-by: multica-agent <github@multica.ai> * feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080) * feat(chat): Settings toggle for the floating chat window (MUL-4235) Re-introduce the floating chat overlay on top of Chat V2 as an optional, Settings-gated surface instead of deleting it outright. - Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted client preference, default ON) to show/hide the floating window. - FloatingChat wrapper owns the two gates: the preference, and the /chat route (hidden on the tab so the same activeSessionId isn't shown twice). - ChatFab + a compact ChatWindow that reuse the shared useChatController and conversation components, so activeSessionId stays in lockstep with the tab. - Restore use-chat-context-items so the overlay's @ surfaces the current issue/project (the 'current context' affordance) — the tab stays manual. - i18n (en/zh-Hans/ja/ko), store unit tests. typecheck: core/views/web/desktop green. tests: chat store 9, settings 82, chat 39 pass. Co-authored-by: multica-agent <github@multica.ai> * feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235) Address review: give Chat its own Settings tab instead of a section inside Preferences, and default the floating window OFF (opt-in). - New Settings → Chat tab (chat-tab.tsx) under My Account; moves the floating-window toggle out of the Preferences tab. - floatingChatEnabled now defaults OFF — only an explicit enable from the Chat tab mounts the FAB/overlay. - i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko); revert the Preferences chat section and its test mock; store tests updated for the opt-in default. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081) The three starter prompts (List my open tasks by priority / Summarize what I did today / Plan what to work on next) read as filler more than help, so remove them along with the now-unused returning_subtitle ("Try asking"). The empty state keeps its agent-aware header — avatar + "Chat with {name}" + optional description — and the composer stays the entry point. Locale keys dropped across en/zh-Hans/ja/ko (parity preserved). Based on the Chat V2 branch (parent MUL-4171, #5076), not main. Co-authored-by: Lambda <lambda@multica.ai> * feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082) Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can keep important chats at the top of the IM-style thread list, above the activity-sorted rest. Backend: - migration 148: chat_session.pinned_at (nullable) + partial index; the timestamp doubles as the pinned-group sort key and the boolean flag. - list queries order pinned-first, then by most-recent activity. - SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler; pinning never bumps updated_at, so an unpinned chat won't jump the list. - ChatSessionResponse.pinned + chat:session_updated carries the new state. Frontend: - ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned with optimistic re-sort; shared sortChatSessions comparator. - thread list: pin indicator on pinned rows + pin/unpin hover action; list sorted pinned-first so it stays ordered after cache patches. - realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings. Tests: SetChatSessionPinned handler test, sortChatSessions unit tests. * feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088) Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093) Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the selected bg-accent reads as an inset rounded card — same treatment the chat thread list already uses — instead of a full-bleed, sharp-cornered highlight. Content stays 16px-inset (p-1 + px-3 == old px-4). MUL-4253 Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092) Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): stop welcome intro session repeating the same introduction (MUL-4259) The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user. Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address review findings + unbreak CI (MUL-4171) - task:failed now refreshes the sessions list (invalidateSessionLists), so the thread-list preview / unread / sort stays correct after an agent failure — FailTask persists a failure chat_message but only broadcasts task:failed, mirroring the chat:done success path. - Self-heal stale chat deep links: once the sessions list has loaded and a ?session= id isn't in it (deleted / no access / never existed) with nothing in flight, clear the selection instead of rendering an editable empty chat that would POST into a nonexistent session. Freshly-created sessions are exempt (they carry optimistic messages + a pending task). - CI: add the new parameterless `chat` route to link-handler's WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set + expectedSegments) — keeps the two in sync, fixes the failing @multica/core test. - Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to EmptyState. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171) Re-review follow-up: the stale-session self-heal only lived in ChatPage, so the floating ChatWindow still entered from a persisted activeSessionId and would render an editable empty chat (then POST into a nonexistent session) when the selected session was deleted / lost access off the /chat route. - Move the self-heal into the shared useChatController so every surface (tab and floating window) drops a dangling activeSessionId once the sessions list has loaded and doesn't contain it. - Harden ensureSession: trust the current id only when it's in the loaded list or is a just-created session still awaiting the refetch; a dangling id falls through to create a fresh session instead of POSTing into a 404. - Exempt just-created sessions via an OPTIMISTIC-write signal (hasOptimisticInFlight: pending task or optimistic- message), not hasMessages — a session deleted elsewhere with real cached history stays eligible for self-heal. Add a unit test for the discriminator. Co-authored-by: multica-agent <github@multica.ai> * test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171) The AppSidebar personal nav gained a `chat` item, so it calls `useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't been updated, so `p.chat` was undefined and every render threw `TypeError: p[item.key] is not a function`, failing @multica/views#test in CI. - Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths. - Route the chat-sessions query key through a mutable `chatSessions` fixture. - Add coverage for the Chat nav: renders the link, badges the summed unread_count, and hides the badge when all sessions are read — so this drift is caught next time. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102) Follow-up to the merged #5080, which shipped a hand-written, simplified ChatWindow and lost the original's animations / drag-resize / expand-minimize. The floating window is just a quick entry point — it should be the original UI, not a rewrite. - Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and use-chat-resize.ts verbatim from main (0-diff): motion animations, drag resize, expand/minimize and the session dropdown are back. - Restore the empty_state.returning_subtitle + starter_prompts i18n keys the original window renders (V2 had dropped them); drop the now-unused window.open_full_tooltip key the rewrite added. - Settings gating is unchanged: FloatingChat still wraps the original FAB + window, gated by floatingChatEnabled (default off) and hidden on /chat. typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098) Restore an archive flow as the reversible sibling of delete: - Chat list hover now offers Archive (not Delete); pin/stop unchanged. - A footer entry ('Archived · N') opens an Archived view listing archived chats; hard delete lives only there (hover -> unarchive + delete, with the existing inline confirm). - Conversation header ⋯ menu mirrors this: active chats archive, archived chats unarchive/delete. Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived (SetChatSessionArchived), broadcasts status on chat:session_updated so other tabs re-sort into the right list. SendChatMessage already refuses archived sessions, so archived chats stay read-only until unarchived. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100) * feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) Co-authored-by: multica-agent <github@multica.ai> * refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265) Co-authored-by: multica-agent <github@multica.ai> * feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265) Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address floating-window + archived-agent review blockers (MUL-4171) Re-review follow-up on the restored floating ChatWindow + archive flow: 1. Floating stale-session self-heal. The restored ChatWindow doesn't use the shared controller, so its ensureSession trusted any non-empty activeSessionId and there was no dangling-session cleanup — a deleted / no-access persisted session could send into a nonexistent session. Ported the same guard used for the tab: a self-heal effect that clears a dangling activeSessionId once the sessions list has loaded, and ensureSession only trusts an id that's in the list or has an in-flight optimistic write (hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds the optimistic message + pending task before setActiveSession, so a freshly-created session is never mis-cleared. 2. Floating dropdown bypassed archive-first safety. Its active rows offered a hard-delete, letting the floating window destroy active chats and skip the "archive first, delete only from Archived" model. Active rows now ARCHIVE (reversible, one-click) like ChatThreadList; the floating window offers no hard-delete — unarchive/delete live only in the full Chat page's Archived view (reachable via expand). Removed the now-dead delete-confirm machinery. 3. Orphan user message on archived-agent send. SendChatMessage created the chat_message before EnqueueChatTask, which rejects an archived / runtime-less agent — a stale client would land a user message then get a 500, orphaning it. Added a preflight that checks the session agent's archived / runtime state and returns 409 before any mutation, plus a handler test asserting the send is rejected with no message persisted. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
677 lines
24 KiB
Go
677 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// withChatTestWorkspaceCtx injects the workspace+member context that the
|
|
// real chi middleware chain would normally set. SendChatMessage (and most
|
|
// other chat handlers) read workspace ID from ctxWorkspaceID; without this
|
|
// the test harness, which calls handlers directly, gets "invalid workspace
|
|
// id" on the parseUUIDOrBadRequest call inside SendChatMessage.
|
|
func withChatTestWorkspaceCtx(t *testing.T, req *http.Request) *http.Request {
|
|
t.Helper()
|
|
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: util.MustParseUUID(testUserID),
|
|
WorkspaceID: util.MustParseUUID(testWorkspaceID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load test member row: %v", err)
|
|
}
|
|
return req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow))
|
|
}
|
|
|
|
// TestSendChatMessage_LinksAttachments verifies that attachments uploaded
|
|
// against a chat_session (chat_message_id NULL) are back-filled with the
|
|
// message_id when SendChatMessage receives the matching attachment_ids.
|
|
func TestSendChatMessage_LinksAttachments(t *testing.T) {
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = &mockStorage{}
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
agentID := createHandlerTestAgent(t, "ChatSendAttachAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
// 1. Upload a file against the chat session.
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, _ := writer.CreateFormFile("file", "send-link.png")
|
|
part.Write([]byte("\x89PNG\r\n\x1a\nbytes"))
|
|
writer.WriteField("chat_session_id", sessionID)
|
|
writer.Close()
|
|
|
|
uploadReq := httptest.NewRequest("POST", "/api/upload-file", &body)
|
|
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
|
|
uploadReq.Header.Set("X-User-ID", testUserID)
|
|
uploadReq.Header.Set("X-Workspace-ID", testWorkspaceID)
|
|
|
|
uploadW := httptest.NewRecorder()
|
|
testHandler.UploadFile(uploadW, uploadReq)
|
|
if uploadW.Code != http.StatusOK {
|
|
t.Fatalf("upload precondition: %d %s", uploadW.Code, uploadW.Body.String())
|
|
}
|
|
var uploadResp AttachmentResponse
|
|
if err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResp); err != nil {
|
|
t.Fatalf("decode upload: %v", err)
|
|
}
|
|
attachmentID := uploadResp.ID
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID)
|
|
})
|
|
|
|
// 2. Send a chat message that references the attachment.
|
|
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
|
|
"content": "look at this ",
|
|
"attachment_ids": []string{attachmentID},
|
|
})
|
|
sendReq = withURLParam(sendReq, "sessionId", sessionID)
|
|
sendReq = withChatTestWorkspaceCtx(t, sendReq)
|
|
sendW := httptest.NewRecorder()
|
|
testHandler.SendChatMessage(sendW, sendReq)
|
|
if sendW.Code != http.StatusCreated {
|
|
t.Fatalf("SendChatMessage: expected 201, got %d: %s", sendW.Code, sendW.Body.String())
|
|
}
|
|
|
|
var sendResp SendChatMessageResponse
|
|
if err := json.Unmarshal(sendW.Body.Bytes(), &sendResp); err != nil {
|
|
t.Fatalf("decode send: %v", err)
|
|
}
|
|
if sendResp.MessageID == "" {
|
|
t.Fatal("expected non-empty message_id in send response")
|
|
}
|
|
if sendResp.TaskID == "" {
|
|
t.Fatal("expected non-empty task_id in send response")
|
|
}
|
|
|
|
var messageTaskID string
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT COALESCE(task_id::text, '') FROM chat_message WHERE id = $1`,
|
|
sendResp.MessageID,
|
|
).Scan(&messageTaskID); err != nil {
|
|
t.Fatalf("query chat message task id: %v", err)
|
|
}
|
|
if messageTaskID != sendResp.TaskID {
|
|
t.Fatalf("chat message task_id mismatch: want %s, got %s", sendResp.TaskID, messageTaskID)
|
|
}
|
|
|
|
// 3. Verify the attachment row now points at the new message.
|
|
var dbMessageID *string
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT chat_message_id::text FROM attachment WHERE id = $1`,
|
|
attachmentID,
|
|
).Scan(&dbMessageID); err != nil {
|
|
t.Fatalf("query attachment: %v", err)
|
|
}
|
|
if dbMessageID == nil {
|
|
t.Fatal("chat_message_id is still NULL after send")
|
|
}
|
|
if *dbMessageID != sendResp.MessageID {
|
|
t.Fatalf("chat_message_id mismatch: want %s, got %s", sendResp.MessageID, *dbMessageID)
|
|
}
|
|
}
|
|
|
|
// TestSendChatMessage_ArchivedAgent verifies that sending to a session whose
|
|
// agent was archived is rejected with 409 BEFORE any message is persisted.
|
|
// EnqueueChatTask rejects an archived agent, but only after CreateChatMessage;
|
|
// without the handler's preflight a stale client would leave an orphan user
|
|
// message with no task or reply.
|
|
func TestSendChatMessage_ArchivedAgent(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatArchivedAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
// Archive the agent out from under the (stale) client.
|
|
if _, err := testPool.Exec(
|
|
context.Background(),
|
|
`UPDATE agent SET archived_at = now() WHERE id = $1`,
|
|
agentID,
|
|
); err != nil {
|
|
t.Fatalf("archive agent: %v", err)
|
|
}
|
|
|
|
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
|
|
"content": "still there?",
|
|
})
|
|
sendReq = withURLParam(sendReq, "sessionId", sessionID)
|
|
sendReq = withChatTestWorkspaceCtx(t, sendReq)
|
|
sendW := httptest.NewRecorder()
|
|
testHandler.SendChatMessage(sendW, sendReq)
|
|
|
|
if sendW.Code != http.StatusConflict {
|
|
t.Fatalf("SendChatMessage to archived agent: expected 409, got %d: %s", sendW.Code, sendW.Body.String())
|
|
}
|
|
|
|
// The rejected send must not have persisted an orphan user message.
|
|
var count int
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT count(*) FROM chat_message WHERE chat_session_id = $1`,
|
|
sessionID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count chat messages: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected no chat_message rows after rejected send, got %d", count)
|
|
}
|
|
}
|
|
|
|
// TestSendChatMessage_LinksUnattachedAttachments verifies the new compose
|
|
// path: upload creates a workspace-scoped unattached attachment, and chat send
|
|
// binds it to both the session and the user message.
|
|
func TestSendChatMessage_LinksUnattachedAttachments(t *testing.T) {
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = &mockStorage{}
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
agentID := createHandlerTestAgent(t, "ChatSendUnattachedAttachAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, _ := writer.CreateFormFile("file", "send-unattached.png")
|
|
part.Write([]byte("\x89PNG\r\n\x1a\nbytes"))
|
|
writer.Close()
|
|
|
|
uploadReq := httptest.NewRequest("POST", "/api/upload-file", &body)
|
|
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
|
|
uploadReq.Header.Set("X-User-ID", testUserID)
|
|
uploadReq.Header.Set("X-Workspace-ID", testWorkspaceID)
|
|
|
|
uploadW := httptest.NewRecorder()
|
|
testHandler.UploadFile(uploadW, uploadReq)
|
|
if uploadW.Code != http.StatusOK {
|
|
t.Fatalf("upload precondition: %d %s", uploadW.Code, uploadW.Body.String())
|
|
}
|
|
var uploadResp AttachmentResponse
|
|
if err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResp); err != nil {
|
|
t.Fatalf("decode upload: %v", err)
|
|
}
|
|
attachmentID := uploadResp.ID
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID)
|
|
})
|
|
if uploadResp.ChatSessionID != nil {
|
|
t.Fatalf("pre-send chat_session_id should be nil, got %v", *uploadResp.ChatSessionID)
|
|
}
|
|
if uploadResp.ChatMessageID != nil {
|
|
t.Fatalf("pre-send chat_message_id should be nil, got %v", *uploadResp.ChatMessageID)
|
|
}
|
|
|
|
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
|
|
"content": "look at this ",
|
|
"attachment_ids": []string{attachmentID},
|
|
})
|
|
sendReq = withURLParam(sendReq, "sessionId", sessionID)
|
|
sendReq = withChatTestWorkspaceCtx(t, sendReq)
|
|
sendW := httptest.NewRecorder()
|
|
testHandler.SendChatMessage(sendW, sendReq)
|
|
if sendW.Code != http.StatusCreated {
|
|
t.Fatalf("SendChatMessage: expected 201, got %d: %s", sendW.Code, sendW.Body.String())
|
|
}
|
|
|
|
var sendResp SendChatMessageResponse
|
|
if err := json.Unmarshal(sendW.Body.Bytes(), &sendResp); err != nil {
|
|
t.Fatalf("decode send: %v", err)
|
|
}
|
|
|
|
var dbSessionID, dbMessageID *string
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT chat_session_id::text, chat_message_id::text FROM attachment WHERE id = $1`,
|
|
attachmentID,
|
|
).Scan(&dbSessionID, &dbMessageID); err != nil {
|
|
t.Fatalf("query attachment: %v", err)
|
|
}
|
|
if dbSessionID == nil || *dbSessionID != sessionID {
|
|
t.Fatalf("chat_session_id mismatch: want %s, got %v", sessionID, dbSessionID)
|
|
}
|
|
if dbMessageID == nil || *dbMessageID != sendResp.MessageID {
|
|
t.Fatalf("chat_message_id mismatch: want %s, got %v", sendResp.MessageID, dbMessageID)
|
|
}
|
|
}
|
|
|
|
// TestUpdateChatSession_RenamesTitle confirms PATCH writes the new title,
|
|
// returns the updated row, and the server-side row reflects it.
|
|
func TestUpdateChatSession_RenamesTitle(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatRenameAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{
|
|
"title": " Renamed Session ",
|
|
})
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.UpdateChatSession(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateChatSession: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp ChatSessionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode update: %v", err)
|
|
}
|
|
if resp.Title != "Renamed Session" {
|
|
t.Fatalf("response title: want %q, got %q", "Renamed Session", resp.Title)
|
|
}
|
|
|
|
var dbTitle string
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT title FROM chat_session WHERE id = $1`,
|
|
sessionID,
|
|
).Scan(&dbTitle); err != nil {
|
|
t.Fatalf("query chat_session: %v", err)
|
|
}
|
|
if dbTitle != "Renamed Session" {
|
|
t.Fatalf("db title: want %q, got %q", "Renamed Session", dbTitle)
|
|
}
|
|
}
|
|
|
|
// TestSetChatSessionPinned_TogglesPin confirms PATCH /pin stamps pinned_at on
|
|
// pin and clears it on unpin, returns the new state, and does not bump
|
|
// updated_at (pinning is a list-ordering preference, not activity).
|
|
func TestSetChatSessionPinned_TogglesPin(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatPinAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
var updatedBefore time.Time
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT updated_at FROM chat_session WHERE id = $1`,
|
|
sessionID,
|
|
).Scan(&updatedBefore); err != nil {
|
|
t.Fatalf("query updated_at: %v", err)
|
|
}
|
|
|
|
pin := func(pinned bool) ChatSessionResponse {
|
|
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID+"/pin", map[string]any{
|
|
"pinned": pinned,
|
|
})
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.SetChatSessionPinned(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SetChatSessionPinned(%v): expected 200, got %d: %s", pinned, w.Code, w.Body.String())
|
|
}
|
|
var resp ChatSessionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode pin: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// Pin.
|
|
if resp := pin(true); !resp.Pinned {
|
|
t.Fatalf("pin=true response Pinned: want true, got false")
|
|
}
|
|
var pinnedAt *time.Time
|
|
var updatedAfter time.Time
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT pinned_at, updated_at FROM chat_session WHERE id = $1`,
|
|
sessionID,
|
|
).Scan(&pinnedAt, &updatedAfter); err != nil {
|
|
t.Fatalf("query pinned_at: %v", err)
|
|
}
|
|
if pinnedAt == nil {
|
|
t.Fatalf("pinned_at: want non-null after pin, got null")
|
|
}
|
|
if !updatedAfter.Equal(updatedBefore) {
|
|
t.Fatalf("updated_at must not change on pin: before %v, after %v", updatedBefore, updatedAfter)
|
|
}
|
|
|
|
// Unpin.
|
|
if resp := pin(false); resp.Pinned {
|
|
t.Fatalf("pin=false response Pinned: want false, got true")
|
|
}
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT pinned_at FROM chat_session WHERE id = $1`,
|
|
sessionID,
|
|
).Scan(&pinnedAt); err != nil {
|
|
t.Fatalf("query pinned_at after unpin: %v", err)
|
|
}
|
|
if pinnedAt != nil {
|
|
t.Fatalf("pinned_at: want null after unpin, got %v", *pinnedAt)
|
|
}
|
|
}
|
|
|
|
// TestSetChatSessionArchived_TogglesStatus archives then unarchives a session,
|
|
// asserting the response + DB status column flip and that updated_at is bumped
|
|
// (so the row re-sorts in whichever list it lands).
|
|
func TestSetChatSessionArchived_TogglesStatus(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatArchiveAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
archive := func(archived bool) ChatSessionResponse {
|
|
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID+"/archive", map[string]any{
|
|
"archived": archived,
|
|
})
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.SetChatSessionArchived(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SetChatSessionArchived(%v): expected 200, got %d: %s", archived, w.Code, w.Body.String())
|
|
}
|
|
var resp ChatSessionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode archive: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
dbStatus := func() string {
|
|
var status string
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT status FROM chat_session WHERE id = $1`,
|
|
sessionID,
|
|
).Scan(&status); err != nil {
|
|
t.Fatalf("query status: %v", err)
|
|
}
|
|
return status
|
|
}
|
|
|
|
// Archive.
|
|
if resp := archive(true); resp.Status != "archived" {
|
|
t.Fatalf("archive=true response Status: want archived, got %q", resp.Status)
|
|
}
|
|
if got := dbStatus(); got != "archived" {
|
|
t.Fatalf("db status after archive: want archived, got %q", got)
|
|
}
|
|
|
|
// Unarchive restores it to active.
|
|
if resp := archive(false); resp.Status != "active" {
|
|
t.Fatalf("archive=false response Status: want active, got %q", resp.Status)
|
|
}
|
|
if got := dbStatus(); got != "active" {
|
|
t.Fatalf("db status after unarchive: want active, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestUpdateChatSession_RejectsBlank refuses an empty/whitespace title with 400.
|
|
// (Untitled is a render-side fallback, not a stored value.)
|
|
func TestUpdateChatSession_RejectsBlank(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatRenameBlankAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{
|
|
"title": " ",
|
|
})
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.UpdateChatSession(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateChatSession blank: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestSendChatMessage_InvalidAttachmentIDs rejects malformed UUIDs in
|
|
// attachment_ids with 400 before any side effects (no message row created).
|
|
func TestSendChatMessage_InvalidAttachmentIDs(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatBadAttachAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
req := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
|
|
"content": "hi",
|
|
"attachment_ids": []string{"not-a-uuid"},
|
|
})
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.SendChatMessage(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("SendChatMessage with bad attachment id: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Confirm no message row was created.
|
|
var count int
|
|
if err := testPool.QueryRow(
|
|
context.Background(),
|
|
`SELECT count(*) FROM chat_message WHERE chat_session_id = $1`,
|
|
sessionID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count chat_message: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected 0 chat_message rows after rejected send, got %d", count)
|
|
}
|
|
}
|
|
|
|
func fetchChatMessagesPageForTest(t *testing.T, sessionID string, params url.Values) ChatMessagesPageResponse {
|
|
t.Helper()
|
|
target := "/api/chat/sessions/" + sessionID + "/messages/page"
|
|
if encoded := params.Encode(); encoded != "" {
|
|
target += "?" + encoded
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
|
req.Header.Set("X-User-ID", testUserID)
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListChatMessagesPage(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListChatMessagesPage: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var page ChatMessagesPageResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
|
t.Fatalf("decode page messages: %v", err)
|
|
}
|
|
return page
|
|
}
|
|
|
|
func TestListChatMessagesPage_UsesCursorWithoutChangingLegacyList(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatCursorPaginationAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
for i, content := range []string{"oldest", "middle", "newest"} {
|
|
_, err := testPool.Exec(
|
|
context.Background(),
|
|
`INSERT INTO chat_message (chat_session_id, role, content, created_at)
|
|
VALUES ($1, 'user', $2, timestamp '2026-01-01 00:00:00' + ($3::int * interval '1 second'))`,
|
|
sessionID,
|
|
content,
|
|
i,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert chat message %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
legacyReq := httptest.NewRequest(http.MethodGet, "/api/chat/sessions/"+sessionID+"/messages", nil)
|
|
legacyReq.Header.Set("X-User-ID", testUserID)
|
|
legacyReq = withURLParam(legacyReq, "sessionId", sessionID)
|
|
legacyReq = withChatTestWorkspaceCtx(t, legacyReq)
|
|
legacyW := httptest.NewRecorder()
|
|
testHandler.ListChatMessages(legacyW, legacyReq)
|
|
if legacyW.Code != http.StatusOK {
|
|
t.Fatalf("ListChatMessages: expected 200, got %d: %s", legacyW.Code, legacyW.Body.String())
|
|
}
|
|
var legacy []ChatMessageResponse
|
|
if err := json.Unmarshal(legacyW.Body.Bytes(), &legacy); err != nil {
|
|
t.Fatalf("decode legacy messages: %v", err)
|
|
}
|
|
if len(legacy) != 3 || legacy[0].Content != "oldest" || legacy[2].Content != "newest" {
|
|
t.Fatalf("legacy messages = %#v", legacy)
|
|
}
|
|
|
|
latest := fetchChatMessagesPageForTest(t, sessionID, url.Values{"limit": {"2"}})
|
|
if latest.Limit != 2 || !latest.HasMore || latest.NextCursor == nil {
|
|
t.Fatalf("latest page metadata = %#v", latest)
|
|
}
|
|
if len(latest.Messages) != 2 || latest.Messages[0].Content != "middle" || latest.Messages[1].Content != "newest" {
|
|
t.Fatalf("latest page messages = %#v", latest)
|
|
}
|
|
|
|
older := fetchChatMessagesPageForTest(t, sessionID, url.Values{
|
|
"limit": {"2"},
|
|
"before_created_at": {latest.NextCursor.CreatedAt},
|
|
"before_id": {latest.NextCursor.ID},
|
|
})
|
|
if older.HasMore || older.NextCursor != nil {
|
|
t.Fatalf("older page metadata = %#v", older)
|
|
}
|
|
if len(older.Messages) != 1 || older.Messages[0].Content != "oldest" {
|
|
t.Fatalf("older page messages = %#v", older)
|
|
}
|
|
}
|
|
|
|
func TestListChatMessagesPage_CursorTieBreaksSameTimestampWithoutDupesOrGaps(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatCursorTieBreakAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
contents := []string{"a", "b", "c", "d", "e"}
|
|
for _, content := range contents {
|
|
_, err := testPool.Exec(
|
|
context.Background(),
|
|
`INSERT INTO chat_message (chat_session_id, role, content, created_at)
|
|
VALUES ($1, 'user', $2, timestamp '2026-01-01 00:00:00')`,
|
|
sessionID,
|
|
content,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert chat message %q: %v", content, err)
|
|
}
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
var ordered []string
|
|
params := url.Values{"limit": {"2"}}
|
|
for {
|
|
page := fetchChatMessagesPageForTest(t, sessionID, params)
|
|
for _, msg := range page.Messages {
|
|
if seen[msg.ID] {
|
|
t.Fatalf("duplicate message id %s across cursor pages", msg.ID)
|
|
}
|
|
seen[msg.ID] = true
|
|
ordered = append(ordered, msg.Content)
|
|
}
|
|
if !page.HasMore {
|
|
if page.NextCursor != nil {
|
|
t.Fatalf("terminal page has next cursor: %#v", page.NextCursor)
|
|
}
|
|
break
|
|
}
|
|
if page.NextCursor == nil {
|
|
t.Fatalf("has_more page missing next cursor: %#v", page)
|
|
}
|
|
params = url.Values{
|
|
"limit": {"2"},
|
|
"before_created_at": {page.NextCursor.CreatedAt},
|
|
"before_id": {page.NextCursor.ID},
|
|
}
|
|
}
|
|
|
|
if len(ordered) != len(contents) {
|
|
t.Fatalf("expected %d messages across pages, got %d: %v", len(contents), len(ordered), ordered)
|
|
}
|
|
// Pages are newest-window first and chronological within each page. With all
|
|
// timestamps equal, the id tie-break must still produce a deterministic,
|
|
// gap-free traversal.
|
|
for _, content := range contents {
|
|
found := false
|
|
for _, got := range ordered {
|
|
if got == content {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("missing content %q across cursor pages: %v", content, ordered)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListChatMessagesPage_RejectsInvalidLimit(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatPaginationBadLimitAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/chat/sessions/"+sessionID+"/messages/page?limit=0", nil)
|
|
req.Header.Set("X-User-ID", testUserID)
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListChatMessagesPage(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("ListChatMessagesPage invalid limit: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestDeleteChatSession_PrunesChannelChatSessionBinding verifies the
|
|
// application-layer replacement for the channel_chat_session_binding
|
|
// chat_session-FK cascade (MUL-3515 §4): deleting a chat session prunes its
|
|
// channel binding in the same tx that deletes the session row.
|
|
func TestDeleteChatSession_PrunesChannelChatSessionBinding(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "ChatDeleteBindingAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
ctx := context.Background()
|
|
|
|
const appID = "cli_chat_delete_binding"
|
|
const channelChatID = "oc_chat_delete_binding"
|
|
|
|
// channel_* rows have no FK to chat_session/workspace (MUL-3515 §4), so
|
|
// they outlive the helper's chat_session cleanup; clear by deterministic
|
|
// key before and after.
|
|
cleanChannel := func() {
|
|
_, _ = testPool.Exec(context.Background(),
|
|
`DELETE FROM channel_chat_session_binding WHERE channel_chat_id = $1`, channelChatID)
|
|
_, _ = testPool.Exec(context.Background(),
|
|
`DELETE FROM channel_installation WHERE channel_type = 'feishu' AND config->>'app_id' = $1`, appID)
|
|
}
|
|
cleanChannel()
|
|
t.Cleanup(cleanChannel)
|
|
|
|
var installID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id)
|
|
VALUES ($1, $2, 'feishu', jsonb_build_object('app_id', $3::text), $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, appID, testUserID).Scan(&installID); err != nil {
|
|
t.Fatalf("insert channel_installation: %v", err)
|
|
}
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO channel_chat_session_binding (chat_session_id, installation_id, channel_type, channel_chat_id, chat_type)
|
|
VALUES ($1, $2, 'feishu', $3, 'p2p')
|
|
`, sessionID, installID, channelChatID); err != nil {
|
|
t.Fatalf("insert channel_chat_session_binding: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/chat/sessions/"+sessionID, nil)
|
|
req.Header.Set("X-User-ID", testUserID)
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := httptest.NewRecorder()
|
|
testHandler.DeleteChatSession(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteChatSession: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var bindingExists bool
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT EXISTS (SELECT 1 FROM channel_chat_session_binding WHERE channel_chat_id = $1)`, channelChatID).Scan(&bindingExists); err != nil {
|
|
t.Fatalf("query chat session binding: %v", err)
|
|
}
|
|
if bindingExists {
|
|
t.Fatal("deleted chat session's channel_chat_session_binding was not pruned")
|
|
}
|
|
}
|