mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 10:08:38 +02:00
Archiving a channel-bound chat session now severs its channel_chat_session_binding in the same tx as the status flip. The web send path already treats status='archived' as read-only, but the channel engine (Feishu/Slack) resolves inbound traffic through the binding without checking session status, so an archived-but-still-bound session kept receiving agent replies and a stuck, uncleared unread badge. Dropping the binding makes the next inbound message fork a fresh session; unarchive does NOT recreate it (a later session may already own the channel). The FAB unread badge counted status=all sessions without excluding archived, so residual unread on an archived session (archive does not mark-read) held the badge even though the session is hidden and read-only. Extract countUnreadChatSessions() and exclude archived. This matches what mobile already computes (active-only list), restoring count parity. Tests: backend archive-clears-binding + unarchive-does-not-recreate; frontend countUnreadChatSessions archived-exclusion cases. MUL-4372 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { TaskMessagePayload } from "../types/events";
|
|
import type { ChatSession } from "../types/chat";
|
|
import {
|
|
countUnreadChatSessions,
|
|
isTaskMessageTaskId,
|
|
mergeTaskMessagesBySeq,
|
|
sortChatSessions,
|
|
taskMessagesOptions,
|
|
} from "./queries";
|
|
|
|
const msg = (seq: number): TaskMessagePayload => ({
|
|
task_id: "task-1",
|
|
issue_id: "issue-1",
|
|
seq,
|
|
type: "text",
|
|
content: `m${seq}`,
|
|
});
|
|
|
|
describe("taskMessagesOptions", () => {
|
|
it("fetches task messages for persisted UUID task ids", () => {
|
|
const taskId = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc";
|
|
|
|
expect(isTaskMessageTaskId(taskId)).toBe(true);
|
|
expect(taskMessagesOptions(taskId).enabled).toBe(true);
|
|
});
|
|
|
|
it("does not fetch task messages for optimistic task ids", () => {
|
|
const taskId = "optimistic-optimistic-1778739487737";
|
|
|
|
expect(isTaskMessageTaskId(taskId)).toBe(false);
|
|
expect(taskMessagesOptions(taskId).enabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("mergeTaskMessagesBySeq", () => {
|
|
it("backfills missing seqs and keeps the list seq-ordered", () => {
|
|
const existing = [msg(1), msg(3)];
|
|
const merged = mergeTaskMessagesBySeq(existing, [msg(2), msg(4)]);
|
|
|
|
expect(merged.map((m) => m.seq)).toEqual([1, 2, 3, 4]);
|
|
});
|
|
|
|
it("drops duplicate seqs and lets the existing entry win", () => {
|
|
const existing = [{ ...msg(1), content: "ws" }];
|
|
const merged = mergeTaskMessagesBySeq(existing, [
|
|
{ ...msg(1), content: "refetch" },
|
|
msg(2),
|
|
]);
|
|
|
|
expect(merged.map((m) => m.seq)).toEqual([1, 2]);
|
|
expect(merged.find((m) => m.seq === 1)?.content).toBe("ws");
|
|
});
|
|
|
|
it("preserves the array reference when nothing new arrives", () => {
|
|
const existing = [msg(1), msg(2)];
|
|
|
|
// Empty incoming and fully-duplicate incoming must both no-op so React
|
|
// Query observers don't re-render on replayed events.
|
|
expect(mergeTaskMessagesBySeq(existing, [])).toBe(existing);
|
|
expect(mergeTaskMessagesBySeq(existing, [msg(1), msg(2)])).toBe(existing);
|
|
});
|
|
});
|
|
|
|
describe("sortChatSessions", () => {
|
|
const session = (over: Partial<ChatSession>): ChatSession => ({
|
|
id: "s",
|
|
workspace_id: "w",
|
|
agent_id: "a",
|
|
creator_id: "c",
|
|
title: "t",
|
|
status: "active",
|
|
has_unread: false,
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
...over,
|
|
});
|
|
|
|
it("puts pinned sessions before unpinned ones regardless of activity", () => {
|
|
const pinnedOld = session({ id: "pinned-old", pinned: true, updated_at: "2026-01-01T00:00:00Z" });
|
|
const unpinnedNew = session({ id: "unpinned-new", pinned: false, updated_at: "2026-06-01T00:00:00Z" });
|
|
|
|
const sorted = sortChatSessions([unpinnedNew, pinnedOld]);
|
|
expect(sorted.map((s) => s.id)).toEqual(["pinned-old", "unpinned-new"]);
|
|
});
|
|
|
|
it("orders within each group by most-recent activity (last message wins over updated_at)", () => {
|
|
const a = session({
|
|
id: "a",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
last_message: { content: "x", role: "assistant", created_at: "2026-06-02T00:00:00Z" },
|
|
});
|
|
const b = session({ id: "b", updated_at: "2026-06-01T00:00:00Z" });
|
|
|
|
const sorted = sortChatSessions([b, a]);
|
|
expect(sorted.map((s) => s.id)).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("does not mutate the input array", () => {
|
|
const input = [session({ id: "1" }), session({ id: "2", pinned: true })];
|
|
const snapshot = input.map((s) => s.id);
|
|
sortChatSessions(input);
|
|
expect(input.map((s) => s.id)).toEqual(snapshot);
|
|
});
|
|
});
|
|
|
|
describe("countUnreadChatSessions", () => {
|
|
const session = (over: Partial<ChatSession>): ChatSession => ({
|
|
id: "s",
|
|
workspace_id: "w",
|
|
agent_id: "a",
|
|
creator_id: "c",
|
|
title: "t",
|
|
status: "active",
|
|
has_unread: false,
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
...over,
|
|
});
|
|
|
|
it("counts only active sessions that have unread", () => {
|
|
const sessions = [
|
|
session({ id: "active-unread", status: "active", has_unread: true }),
|
|
session({ id: "active-read", status: "active", has_unread: false }),
|
|
];
|
|
|
|
expect(countUnreadChatSessions(sessions)).toBe(1);
|
|
});
|
|
|
|
it("excludes archived sessions even when they carry unread", () => {
|
|
// The stuck-badge bug: an archived session keeps has_unread, but it is
|
|
// hidden from the default list and read-only, so the badge must ignore it
|
|
// (MUL-4372).
|
|
const sessions = [
|
|
session({ id: "archived-unread", status: "archived", has_unread: true }),
|
|
session({ id: "active-unread", status: "active", has_unread: true }),
|
|
];
|
|
|
|
expect(countUnreadChatSessions(sessions)).toBe(1);
|
|
});
|
|
|
|
it("returns 0 when the only unread sessions are archived", () => {
|
|
const sessions = [
|
|
session({ id: "archived-1", status: "archived", has_unread: true }),
|
|
session({ id: "archived-2", status: "archived", has_unread: true }),
|
|
];
|
|
|
|
expect(countUnreadChatSessions(sessions)).toBe(0);
|
|
});
|
|
});
|