Files
multica/packages/core/chat/queries.test.ts
Naiyuan Qing 0b054b5c8b fix(chat): clear external channel binding on archive, drop archived from FAB unread (#5214)
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>
2026-07-13 10:01:13 +08:00

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);
});
});