142 lines
5.9 KiB
TypeScript
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");
|
|
});
|
|
});
|