Files
kandev/apps/web/lib/session-unread-divider.test.ts

142 lines
5.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
import type { RenderItem } from "@/hooks/use-processed-messages";
import { findUnreadDividerItemId, lastRenderedMessageId } from "./session-unread-divider";
function makeMessage(id: string, content = ""): Message {
return {
id,
session_id: toSessionId("s1"),
task_id: toTaskId("t1"),
author_type: "agent",
content,
type: "message",
created_at: "",
};
}
const messageItem = (message: Message): RenderItem => ({ type: "message", message });
const turnGroup = (id: string, messages: Message[]): RenderItem => ({
type: "turn_group",
id,
turnId: messages[0]?.turn_id ?? null,
messages,
});
const prepareProgress = (id: string): RenderItem => ({
type: "prepare_progress",
id,
sessionId: "s1",
});
describe("findUnreadDividerItemId", () => {
it("returns null when there is no read cursor yet", () => {
const items = [messageItem(makeMessage("m1"))];
expect(findUnreadDividerItemId(items, null)).toBeNull();
expect(findUnreadDividerItemId(items, undefined)).toBeNull();
expect(findUnreadDividerItemId(items, "")).toBeNull();
});
it("places the divider before the next standalone message after the cursor", () => {
const items = [
messageItem(makeMessage("m1")),
messageItem(makeMessage("m2")),
messageItem(makeMessage("m3")),
];
expect(findUnreadDividerItemId(items, "m1")).toBe("m2");
});
it("returns null when the cursor already points at the newest message", () => {
const items = [messageItem(makeMessage("m1")), messageItem(makeMessage("m2"))];
expect(findUnreadDividerItemId(items, "m2")).toBeNull();
});
it("treats a turn_group as a single unit — a cursor inside the group places the divider after the whole group, not mid-group", () => {
const a1 = makeMessage("a1");
const a2 = makeMessage("a2");
const a3 = makeMessage("a3");
const items = [turnGroup("turn-group-a1", [a1, a2, a3]), messageItem(makeMessage("m-after"))];
// Cursor sits on the middle message of the group — the divider must not
// split the group, so it lands before the next item entirely.
expect(findUnreadDividerItemId(items, "a2")).toBe("m-after");
});
it("returns null when the cursor is the last message inside the newest turn_group", () => {
const a1 = makeMessage("a1");
const a2 = makeMessage("a2");
const items = [turnGroup("turn-group-a1", [a1, a2])];
expect(findUnreadDividerItemId(items, "a2")).toBeNull();
});
it("skips non-message items (prepare_progress) when locating the next unread message", () => {
const items = [
messageItem(makeMessage("m1")),
prepareProgress("prepare-1"),
messageItem(makeMessage("m2")),
];
expect(findUnreadDividerItemId(items, "m1")).toBe("m2");
});
it("treats a cursor missing from the loaded window as the whole window being unread, placing the divider before its first message", () => {
// Simulates returning to an old task: the persisted cursor points at a
// message far older than what pagination has loaded so far.
const items = [messageItem(makeMessage("m10")), messageItem(makeMessage("m11"))];
expect(findUnreadDividerItemId(items, "m1-not-loaded")).toBe("m10");
});
it("ignores the synthetic task-description placeholder as a cursor boundary", () => {
// A CREATED session with no real messages yet renders only the
// frontend-only task-description placeholder. It must never satisfy a
// real lastReadMessageId lookup or be treated as a message to place the
// divider after.
const items = [messageItem(makeMessage("task-description"))];
expect(findUnreadDividerItemId(items, "m1")).toBeNull();
});
});
describe("lastRenderedMessageId", () => {
it("returns null when there are no items", () => {
expect(lastRenderedMessageId([])).toBeNull();
});
it("returns the last standalone message's id", () => {
const items = [messageItem(makeMessage("m1")), messageItem(makeMessage("m2"))];
expect(lastRenderedMessageId(items)).toBe("m2");
});
it("skips trailing non-message items (prepare_progress) to find the last real message", () => {
const items = [messageItem(makeMessage("m1")), prepareProgress("prepare-1")];
expect(lastRenderedMessageId(items)).toBe("m1");
});
it("returns the last message inside a trailing turn_group", () => {
const items = [
messageItem(makeMessage("m1")),
turnGroup("turn-group-a1", [makeMessage("a1"), makeMessage("a2")]),
];
expect(lastRenderedMessageId(items)).toBe("a2");
});
it("is unaffected by a trailing item that never rendered (e.g. filtered from the backend list)", () => {
// Mirrors the bug findUnreadDividerItemId's caller must avoid: the raw
// backend list's newest row (a hidden clarification/status/setup-output
// message) never became a render item, so it must not be reported here.
const items = [messageItem(makeMessage("m1")), messageItem(makeMessage("m2"))];
expect(lastRenderedMessageId(items)).toBe("m2");
expect(lastRenderedMessageId(items)).not.toBe("m3-hidden-from-render");
});
it("returns null for a CREATED session with only the synthetic task-description placeholder", () => {
// use-processed-messages.ts injects this placeholder only when there are
// zero real messages. It must never be reported as "the latest message"
// — doing so would make the mark-read call target a message id that
// doesn't exist in the backend on every visit to a brand-new session.
const items = [messageItem(makeMessage("task-description"))];
expect(lastRenderedMessageId(items)).toBeNull();
});
it("skips a trailing synthetic task-description placeholder to find the last real message", () => {
const items = [messageItem(makeMessage("m1")), messageItem(makeMessage("task-description"))];
expect(lastRenderedMessageId(items)).toBe("m1");
});
});