Q142: a conversation is read only up to the message the reader actually saw

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 23:18:06 -04:00
1 parent 87c297dd45
commit b18eb9ad14
11 files changed
+167 -55

No files matched your search

+33
View File
@@ -133,6 +133,39 @@ test("the pinned CEO conversation is the Messages front door", async () => {
await expect(root).toContainText("1 reply");
await thread.getByRole("button", { name: "Close thread" }).click();
await expect(thread).toHaveCount(0);
// A message that arrives while the reader is up in history stays unread until they scroll down to it.
await page.evaluate(async () => {
const api = (window as unknown as { agentCompany: {
loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string; employees: Array<{ id: string; orgId: string }>; chatSpaces: Array<{ id: string; orgId: string; spaceType: string }> } }>;
sendMessage: (input: unknown) => Promise<unknown>;
} }).agentCompany;
const snapshot = (await api.loadSnapshot()).data;
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId)!;
const ceoChat = snapshot.chatSpaces.find((item) => item.orgId === snapshot.activeOrgId && item.spaceType === "direct")!;
for (let index = 0; index < 40; index += 1) {
await api.sendMessage({ orgId: snapshot.activeOrgId, spaceId: ceoChat.id, threadId: null, senderType: "employee", senderId: employee.id, bodyMd: `History line ${index}`, attachmentFileIds: [] });
}
});
await expect(chatLog.getByText("History line 39")).toBeVisible();
await chatLog.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await page.evaluate(async () => {
const api = (window as unknown as { agentCompany: {
loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string; agents: Array<{ id: string; orgId: string; roleLevel: string }>; chatSpaces: Array<{ id: string; orgId: string; spaceType: string }> } }>;
sendMessage: (input: unknown) => Promise<unknown>;
} }).agentCompany;
const snapshot = (await api.loadSnapshot()).data;
const ceo = snapshot.agents.find((agent) => agent.orgId === snapshot.activeOrgId && agent.roleLevel === "c_suite")!;
const ceoChat = snapshot.chatSpaces.find((item) => item.orgId === snapshot.activeOrgId && item.spaceType === "direct")!;
await api.sendMessage({ orgId: snapshot.activeOrgId, spaceId: ceoChat.id, threadId: null, senderType: "agent", senderId: ceo.id, bodyMd: "An update you have not seen yet.", attachmentFileIds: [] });
});
const ceoRow = page.locator(".convo-item").first();
await expect(ceoRow.locator(".nav-badge")).toHaveText("1");
await page.getByRole("button", { name: "Jump to latest" }).click();
await expect(ceoRow.locator(".nav-badge")).toHaveCount(0);
} finally {
await app.close();
}
+47
View File
@@ -0,0 +1,47 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
import { ChatService } from "../../src/main/services/chat-service";
describe("read state", () => {
test("marks read up to the message the reader saw, never past it and never backwards", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-read-state-${randomUUID()}.db`));
const chat = new ChatService(connection.db);
let orgId = "";
connection.transaction(() => {
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
});
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
const space = (name: string) => (connection.db.prepare("select id from chat_spaces where org_id = ? and name = ?").get(orgId, name) as { id: string }).id;
const general = space("general");
const send = (spaceId: string, bodyMd: string) => connection.transaction(() => chat.sendMessage({
orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd, attachmentFileIds: [],
}));
const createdAt = (id: string) => (connection.db.prepare("select created_at from chat_messages where id = ?").get(id) as { created_at: string }).created_at;
const readState = () => connection.db.prepare("select last_read_message_id as id, last_read_at as at from chat_read_state where space_id = ?").get(general);
const seen = send(general, "Seen");
const later = send(general, "Arrived while scrolled up");
connection.db.prepare("update chat_messages set created_at = ? where id = ?").run("2099-01-01T00:00:00.000Z", later);
chat.markSpaceRead({ orgId, employeeId, spaceId: general, upToMessageId: seen });
expect(readState()).toEqual({ id: seen, at: createdAt(seen) });
chat.markSpaceRead({ orgId, employeeId, spaceId: general, upToMessageId: later });
chat.markSpaceRead({ orgId, employeeId, spaceId: general, upToMessageId: seen });
expect(readState()).toEqual({ id: later, at: "2099-01-01T00:00:00.000Z" });
// Two messages in the same millisecond still order by insertion, so reading the second advances.
const first = send(general, "Same instant one");
const second = send(general, "Same instant two");
connection.db.prepare("update chat_messages set created_at = '2099-06-01T00:00:00.000Z' where id in (?, ?)").run(first, second);
chat.markSpaceRead({ orgId, employeeId, spaceId: general, upToMessageId: first });
chat.markSpaceRead({ orgId, employeeId, spaceId: general, upToMessageId: second });
expect(readState()).toEqual({ id: second, at: "2099-06-01T00:00:00.000Z" });
chat.markSpaceRead({ orgId, employeeId, spaceId: general, upToMessageId: first });
expect(readState()).toEqual({ id: second, at: "2099-06-01T00:00:00.000Z" });
expect(() => chat.markSpaceRead({ orgId, employeeId, spaceId: general, upToMessageId: send(space("engineering"), "Elsewhere") }))
.toThrow("The message read is not in this conversation.");
connection.close();
});
});
+16
View File
@@ -4,6 +4,7 @@ import type {
AgentRecord,
ChatMembershipRecord,
ChatMessageRecord,
ChatReadStateRecord,
ChatSpaceRecord,
EmployeeRecord,
} from "../../src/shared/domain";
@@ -103,3 +104,18 @@ describe("conversation pinning", () => {
expect(conversations[0].preview).toContain("CEO:");
});
});
describe("unread counts", () => {
test("count by position after the last read message, so a same-millisecond arrival is still unread", () => {
const { spaces, memberships } = fixtures();
const at = "2026-02-01T00:00:00.000Z";
const messages = [message("m_read", "spc_general", CEO, at), message("m_same_ms", "spc_general", ENGINEER, at)];
const unread = (readStates: ChatReadStateRecord[]) => buildConversations({ spaces, memberships, messages, readStates, employees, agents, currentEmployeeId: FOUNDER, ceoAgentId: CEO })
.find((item) => item.space.id === "spc_general")!.unread;
const state = (lastReadMessageId: string | null): ChatReadStateRecord => ({ orgId: ORG, employeeId: FOUNDER, spaceId: "spc_general", lastReadMessageId, lastReadAt: at });
expect(unread([state("m_read")])).toBe(1);
expect(unread([state("m_same_ms")])).toBe(0);
// A read message older than the loaded window means every loaded message came after it.
expect(unread([state("m_older_than_window")])).toBe(2);
});
});