Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 132 additions & 2 deletions apps/web/src/domains/chat/api/conversations.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { afterEach, describe, expect, mock, test } from "bun:test";

import { parseConversation } from "@/domains/chat/api/conversations.js";
import { client } from "@/domains/chat/api/client.js";
import { listConversations, parseConversation } from "@/domains/chat/api/conversations.js";

describe("parseConversation — originChannel plumbing", () => {
test("returns null for non-object input", () => {
Expand Down Expand Up @@ -133,6 +134,135 @@ describe("parseConversation — originChannel plumbing", () => {
});
});

describe("parseConversation — displayOrder", () => {
test("captures numeric displayOrder for drag-reordered conversations", () => {
const parsed = parseConversation({
conversationKey: "conv-pinned",
isPinned: true,
displayOrder: 3,
});
expect(parsed?.displayOrder).toBe(3);
});

test("leaves displayOrder undefined when the field is absent", () => {
const parsed = parseConversation({ conversationKey: "conv-fresh" });
expect(parsed?.displayOrder).toBeUndefined();
});

test("treats non-finite displayOrder as missing", () => {
expect(
parseConversation({
conversationKey: "c1",
displayOrder: Number.NaN,
})?.displayOrder,
).toBeUndefined();
expect(
parseConversation({
conversationKey: "c2",
displayOrder: "0",
})?.displayOrder,
).toBeUndefined();
});
});

describe("listConversations — pagination", () => {
const originalGet = client.get;
type GetOptions = {
query?: Record<string, unknown>;
};

type Page = {
conversations: Array<{ conversationKey: string }>;
hasMore?: boolean;
};

function setupPagedResponses(pages: {
foreground: Page[];
background?: Page[];
}): { calls: Array<{ url: unknown; query: Record<string, unknown> | undefined }> } {
const calls: Array<{ url: unknown; query: Record<string, unknown> | undefined }> = [];
const foregroundQueue = [...pages.foreground];
const backgroundQueue = [...(pages.background ?? [{ conversations: [] }])];
client.get = mock(
async (options: GetOptions & { url?: unknown }) => {
calls.push({ url: options.url, query: options.query });
const isBackground = options.query?.conversationType === "background";
const queue = isBackground ? backgroundQueue : foregroundQueue;
const next = queue.shift() ?? { conversations: [], hasMore: false };
return {
data: next,
error: null,
response: new Response(null, { status: 200 }),
};
},
) as typeof client.get;
return { calls };
}

afterEach(() => {
client.get = originalGet;
});

test("loops over pages until hasMore is false (>50 conversations preserved)", async () => {
const page1Items = Array.from({ length: 50 }, (_, i) => ({
conversationKey: `foreground-${i}`,
}));
const page2Items = Array.from({ length: 30 }, (_, i) => ({
conversationKey: `foreground-${50 + i}`,
}));
const { calls } = setupPagedResponses({
foreground: [
{ conversations: page1Items, hasMore: true },
{ conversations: page2Items, hasMore: false },
],
});

const result = await listConversations("assistant-1");

expect(result).toHaveLength(80);
expect(result.at(0)?.conversationKey).toBe("foreground-0");
expect(result.at(-1)?.conversationKey).toBe("foreground-79");
// 2 foreground pages + 1 background page (empty by default). Foreground
// and background fetch in parallel via Promise.allSettled, so filter
// before asserting page offsets.
expect(calls).toHaveLength(3);
const foregroundCalls = calls.filter(
(c) => c.query?.conversationType === undefined,
);
expect(foregroundCalls).toHaveLength(2);
expect(foregroundCalls[0]?.query).toMatchObject({ limit: 50, offset: 0 });
expect(foregroundCalls[1]?.query).toMatchObject({ limit: 50, offset: 50 });
});

test("stops on the first page when hasMore is false or absent", async () => {
const { calls } = setupPagedResponses({
foreground: [
{ conversations: [{ conversationKey: "only-one" }] },
],
});

const result = await listConversations("assistant-1");

expect(result).toHaveLength(1);
// 1 foreground + 1 background
expect(calls).toHaveLength(2);
});

test("does not loop forever on hasMore=true with empty page", async () => {
const { calls } = setupPagedResponses({
foreground: [
{ conversations: [{ conversationKey: "a" }], hasMore: true },
{ conversations: [], hasMore: true },
],
});

const result = await listConversations("assistant-1");

expect(result).toHaveLength(1);
expect(calls).toHaveLength(3); // 2 foreground + 1 background
});
});

describe("parseConversation — Slack channel binding", () => {
test("preserves Slack channel binding with id, name, and link", () => {
const parsed = parseConversation({
Expand Down
111 changes: 81 additions & 30 deletions apps/web/src/domains/chat/api/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export interface Conversation {
isPinned?: boolean;
conversationType?: string;
scheduleJobId?: string;
/**
* Server-provided sort order for pinned and custom-group buckets. Set when
* the user has drag-reordered the conversation; absent for conversations
* that have never been reordered. Consumers (see `groupConversations`)
* should sort pinned / custom-group buckets by this field so the user's
* order is preserved across reloads.
*/
displayOrder?: number;
channelBinding?: ConversationChannelBinding;
/**
* Channel of origin for this conversation, e.g. `"slack"`, `"telegram"`,
Expand Down Expand Up @@ -74,6 +82,7 @@ export interface ConversationSlackThread {

interface ListConversationsResponse {
conversations: Conversation[];
hasMore?: boolean;
}

interface ConversationAttentionPayload {
Expand Down Expand Up @@ -233,46 +242,88 @@ export function parseConversation(raw: unknown): Conversation | null {
typeof record.conversationType === "string" ? record.conversationType : undefined,
scheduleJobId:
typeof record.scheduleJobId === "string" ? record.scheduleJobId : undefined,
displayOrder:
typeof record.displayOrder === "number" && Number.isFinite(record.displayOrder)
? record.displayOrder
: undefined,
channelBinding: parsedChannelBinding,
originChannel,
};
}

/**
* Daemon default page size for `/v1/assistants/{id}/conversations/`. Used
* as our explicit page size so pagination state is predictable across daemon
* versions. See `ConversationListRequest` in
* `assistant/src/daemon/message-types/conversations.ts`.
*/
const CONVERSATION_LIST_PAGE_SIZE = 50;

/**
* Safety cap on the pagination loop. Multiplied by `CONVERSATION_LIST_PAGE_SIZE`
* this allows for 10,000 conversations of a single type — far above any
* realistic user count, but bounded so a malformed `hasMore` from the server
* can't spin forever.
*/
const CONVERSATION_LIST_MAX_PAGES = 200;

async function fetchConversationList(
assistantId: string,
conversationType?: "background",
): Promise<Conversation[]> {
const { data, error, response } = await client.get<
ListConversationsResponse,
unknown
>({
...SDK_BASE_OPTIONS,
url: "/v1/assistants/{assistant_id}/conversations/",
path: { assistant_id: assistantId },
query: conversationType ? { conversationType } : undefined,
throwOnError: false,
});
assertHasResponse(response, error, "Failed to list conversations.");
if (!response.ok) {
const msg = extractErrorMessage(error, response, "Failed to list conversations.");
throw new ApiError(response.status, msg);
const all: Conversation[] = [];

for (let page = 0; page < CONVERSATION_LIST_MAX_PAGES; page++) {
const offset = page * CONVERSATION_LIST_PAGE_SIZE;
const { data, error, response } = await client.get<
ListConversationsResponse,
unknown
>({
...SDK_BASE_OPTIONS,
url: "/v1/assistants/{assistant_id}/conversations/",
path: { assistant_id: assistantId },
query: {
...(conversationType ? { conversationType } : {}),
limit: CONVERSATION_LIST_PAGE_SIZE,
offset,
},
throwOnError: false,
});
assertHasResponse(response, error, "Failed to list conversations.");
if (!response.ok) {
const msg = extractErrorMessage(error, response, "Failed to list conversations.");
throw new ApiError(response.status, msg);
}
const payload =
data && typeof data === "object" && !Array.isArray(data)
? (data as unknown as {
conversations?: unknown;
sessions?: unknown;
hasMore?: unknown;
})
: null;
const rawItems = Array.isArray(payload?.conversations)
? payload.conversations
: Array.isArray(payload?.sessions)
? payload.sessions
: [];

const pageItems = rawItems
.map((conversation) => parseConversation(conversation))
.filter((conversation): conversation is Conversation => conversation !== null);

all.push(...pageItems);

const hasMore =
typeof payload?.hasMore === "boolean" ? payload.hasMore : false;
if (!hasMore) break;

// Defensive: a malformed `hasMore: true` with an empty page would loop
// forever. Treat an empty page as end-of-list regardless of `hasMore`.
if (pageItems.length === 0) break;
}
const payload =
data && typeof data === "object" && !Array.isArray(data)
? (data as unknown as {
conversations?: unknown;
sessions?: unknown;
})
: null;
const rawItems = Array.isArray(payload?.conversations)
? payload.conversations
: Array.isArray(payload?.sessions)
? payload.sessions
: [];

return rawItems
.map((conversation) => parseConversation(conversation))
.filter((conversation): conversation is Conversation => conversation !== null);

return all;
}

/**
Expand Down
Loading