Skip to content

Commit fcdb1cb

Browse files
committed
fix: address message history review feedback
1 parent 1d93455 commit fcdb1cb

5 files changed

Lines changed: 189 additions & 13 deletions

File tree

src/backend/tests/unit/test_messages_endpoints.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,6 @@ async def test_get_messages_does_not_return_other_users_messages(
210210
assert str(cross_user_messages["owned_message"].id) not in other_returned_ids
211211

212212

213-
@pytest.mark.api_key_required
214213
@pytest.mark.usefixtures("timestamped_messages")
215214
async def test_get_messages_defaults_to_timestamp_ascending(client: AsyncClient, logged_in_headers):
216215
response = await client.get(
@@ -223,7 +222,6 @@ async def test_get_messages_defaults_to_timestamp_ascending(client: AsyncClient,
223222
assert [message["text"] for message in response.json()] == ["Message 0", "Message 1", "Message 2"]
224223

225224

226-
@pytest.mark.api_key_required
227225
@pytest.mark.usefixtures("timestamped_messages")
228226
async def test_get_messages_supports_descending_order_with_pagination(client: AsyncClient, logged_in_headers):
229227
response = await client.get(
@@ -236,7 +234,6 @@ async def test_get_messages_supports_descending_order_with_pagination(client: As
236234
assert [message["text"] for message in response.json()] == ["Message 1", "Message 0"]
237235

238236

239-
@pytest.mark.api_key_required
240237
@pytest.mark.usefixtures("timestamped_messages")
241238
async def test_get_messages_rejects_invalid_order_direction(client: AsyncClient, logged_in_headers):
242239
response = await client.get(

src/frontend/src/components/core/playgroundComponent/chat-view/chat-messages/hooks/__tests__/use-chat-history.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe("useChatHistory message ordering", () => {
4949
{
5050
id: "flow-1",
5151
mode: "union",
52-
params: { limit: 20, order: "DESC" },
52+
params: { session_id: "session-1", limit: 20, order: "DESC" },
5353
},
5454
{ enabled: true },
5555
);
@@ -74,4 +74,33 @@ describe("useChatHistory message ordering", () => {
7474
offset: 0,
7575
});
7676
});
77+
78+
it("stops loading when a source repeats the same page", async () => {
79+
const queryClient = new QueryClient({
80+
defaultOptions: { queries: { retry: false } },
81+
});
82+
const repeatedPage = Array.from({ length: 20 }, (_, index) => ({
83+
id: `message-${index}`,
84+
}));
85+
const sessionCacheKey = [
86+
"useGetMessagesQuery",
87+
{ id: "flow-1", session_id: "session-1" },
88+
];
89+
queryClient.setQueryData(sessionCacheKey, repeatedPage);
90+
mockGetMessages
91+
.mockResolvedValueOnce({ data: repeatedPage })
92+
.mockResolvedValueOnce({ data: repeatedPage });
93+
94+
const { result } = renderHook(() => useChatHistory("session-1"), {
95+
wrapper: createWrapper(queryClient),
96+
});
97+
98+
let prepended = -1;
99+
await act(async () => {
100+
prepended = await result.current.loadMore();
101+
});
102+
103+
expect(prepended).toBe(0);
104+
expect(mockGetMessages).toHaveBeenCalledTimes(2);
105+
});
77106
});

src/frontend/src/components/core/playgroundComponent/chat-view/chat-messages/hooks/use-chat-history.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ export const useChatHistory = (visibleSession: string | null) => {
3434
const messageQueryParams: Parameters<typeof useGetMessagesQuery>[0] = {
3535
id: currentFlowId,
3636
mode: "union",
37-
params: { limit: 20, order: "DESC" },
37+
params: {
38+
...(visibleSession ? { session_id: visibleSession } : {}),
39+
limit: 20,
40+
order: "DESC",
41+
},
3842
};
3943
const { data: queryData } = useGetMessagesQuery(messageQueryParams, {
4044
enabled: isPlaygroundOpen,
@@ -97,6 +101,7 @@ export const useChatHistory = (visibleSession: string | null) => {
97101
// remount offsetRef restarts at 0 while the cache may already hold the
98102
// early pages, and dedup would otherwise swallow them and stall.
99103
let prepended = 0;
104+
const seenPageSignatures = new Set<string>();
100105
while (prepended === 0) {
101106
const response = await getMessages(currentFlowId, {
102107
...(visibleSession ? { session_id: visibleSession } : {}),
@@ -105,6 +110,26 @@ export const useChatHistory = (visibleSession: string | null) => {
105110
offset: offsetRef.current,
106111
});
107112
const olderMessages: Message[] = response.data || [];
113+
if (olderMessages.length > 0) {
114+
const pageSignature = JSON.stringify(
115+
olderMessages.map((message) =>
116+
message.id
117+
? message.id
118+
: [
119+
message.flow_id,
120+
message.session_id,
121+
message.timestamp,
122+
message.sender,
123+
message.text,
124+
],
125+
),
126+
);
127+
if (seenPageSignatures.has(pageSignature)) {
128+
setHasMore(false);
129+
break;
130+
}
131+
seenPageSignatures.add(pageSignature);
132+
}
108133
offsetRef.current += olderMessages.length;
109134

110135
const exhausted = olderMessages.length < 20;

src/frontend/src/controllers/API/queries/messages/__tests__/use-get-messages-routing.test.ts

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* - sessionStorage (anonymous/auto-login shareable playground)
88
*/
99

10+
import { renderHook } from "@testing-library/react";
11+
1012
const FLOW_ID = "virtual-flow-id-123";
1113
const SOURCE_FLOW_ID = "real-flow-id-456";
1214
const MOCK_MESSAGES = [
@@ -51,6 +53,7 @@ jest.mock("@/modals/IOModal/helpers/playground-auth", () => ({
5153
}));
5254

5355
const mockApiGet = jest.fn();
56+
const mockQueryKeys: unknown[] = [];
5457
jest.mock("@/controllers/API/api", () => ({
5558
api: { get: (...args: unknown[]) => mockApiGet(...args) },
5659
}));
@@ -61,12 +64,13 @@ jest.mock("@/controllers/API/helpers/constants", () => ({
6164

6265
jest.mock("@/utils/utils", () => ({
6366
extractColumnsFromRows: jest.fn(() => []),
64-
prepareSessionIdForAPI: (id: string) => id,
67+
prepareSessionIdForAPI: (id: string) => encodeURIComponent(id),
6568
}));
6669

6770
jest.mock("@/controllers/API/services/request-processor", () => ({
6871
UseRequestProcessor: jest.fn(() => ({
69-
query: jest.fn((_key: unknown, fn: () => Promise<unknown>) => {
72+
query: jest.fn((key: unknown, fn: () => Promise<unknown>) => {
73+
mockQueryKeys.push(key);
7074
void fn();
7175
return { data: null, isFetched: false, refetch: jest.fn() };
7276
}),
@@ -76,7 +80,7 @@ jest.mock("@/controllers/API/services/request-processor", () => ({
7680
import { isAuthenticatedPlayground } from "@/modals/IOModal/helpers/playground-auth";
7781
import useFlowStore from "@/stores/flowStore";
7882
import { useMessagesStore } from "@/stores/messagesStore";
79-
import { useGetMessagesQuery } from "../use-get-messages";
83+
import { getMessages, useGetMessagesQuery } from "../use-get-messages";
8084

8185
const mockFlowStore = useFlowStore as unknown as { getState: jest.Mock };
8286
const mockIsAuth = isAuthenticatedPlayground as jest.MockedFunction<
@@ -89,6 +93,7 @@ describe("useGetMessagesQuery - Routing Logic", () => {
8993
mockApiGet.mockResolvedValue({ data: MOCK_MESSAGES });
9094
mockFlowStore.getState.mockReturnValue({ playgroundPage: false });
9195
mockIsAuth.mockReturnValue(false);
96+
mockQueryKeys.length = 0;
9297
window.sessionStorage.clear();
9398
});
9499

@@ -172,6 +177,86 @@ describe("useGetMessagesQuery - Routing Logic", () => {
172177
expect(mockApiGet).not.toHaveBeenCalled();
173178
});
174179

180+
it("should_filter_order_and_paginate_anonymous_sessionStorage", async () => {
181+
mockFlowStore.getState.mockReturnValue({ playgroundPage: true });
182+
mockIsAuth.mockReturnValue(false);
183+
window.sessionStorage.setItem(
184+
FLOW_ID,
185+
JSON.stringify([
186+
{
187+
id: "oldest",
188+
session_id: "session one",
189+
timestamp: "2026-01-01T00:00:00Z",
190+
},
191+
{
192+
id: "other-session",
193+
session_id: "session-two",
194+
timestamp: "2026-01-01T00:03:00Z",
195+
},
196+
{
197+
id: "newest",
198+
session_id: "session one",
199+
timestamp: "2026-01-01T00:02:00Z",
200+
},
201+
{
202+
id: "middle",
203+
session_id: "session one",
204+
timestamp: "2026-01-01T00:01:00Z",
205+
},
206+
]),
207+
);
208+
209+
const response = await getMessages(FLOW_ID, {
210+
session_id: "session one",
211+
order: "DESC",
212+
offset: 1,
213+
limit: 1,
214+
});
215+
216+
expect(response.data.map((message) => message.id)).toEqual(["middle"]);
217+
expect(mockApiGet).not.toHaveBeenCalled();
218+
});
219+
220+
it("uses a distinct query key when request params change", () => {
221+
const { rerender } = renderHook(
222+
({ sessionId }) =>
223+
useGetMessagesQuery(
224+
{
225+
id: FLOW_ID,
226+
mode: "union",
227+
excludedFields: ["properties"],
228+
params: { session_id: sessionId, limit: 20, order: "DESC" },
229+
},
230+
{},
231+
),
232+
{ initialProps: { sessionId: "session-1" } },
233+
);
234+
const firstKey = mockQueryKeys[mockQueryKeys.length - 1];
235+
236+
rerender({ sessionId: "session-2" });
237+
const secondKey = mockQueryKeys[mockQueryKeys.length - 1];
238+
239+
expect(firstKey).toEqual([
240+
"useGetMessagesQuery",
241+
{
242+
id: FLOW_ID,
243+
mode: "union",
244+
excludedFields: ["properties"],
245+
params: { session_id: "session-1", limit: 20, order: "DESC" },
246+
},
247+
]);
248+
expect(secondKey).toEqual([
249+
"useGetMessagesQuery",
250+
{
251+
id: FLOW_ID,
252+
mode: "union",
253+
excludedFields: ["properties"],
254+
params: { session_id: "session-2", limit: 20, order: "DESC" },
255+
},
256+
]);
257+
expect(secondKey).not.toEqual(firstKey);
258+
});
259+
175260
it("should_sync_messages_to_zustand_store_when_authenticated_playground", async () => {
176261
mockFlowStore.getState.mockReturnValue({ playgroundPage: true });
177262
mockIsAuth.mockReturnValue(true);

src/frontend/src/controllers/API/queries/messages/use-get-messages.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,42 @@ interface MessagesResponse {
2626
columns: Array<ColDef | ColGroupDef>;
2727
}
2828

29+
const getStoredMessages = (
30+
id: string | undefined,
31+
params: Record<string, unknown>,
32+
): Message[] => {
33+
const storedMessages = JSON.parse(
34+
window.sessionStorage.getItem(id ?? "") || "[]",
35+
) as Message[];
36+
const sessionId =
37+
typeof params.session_id === "string" ? params.session_id : undefined;
38+
const direction =
39+
typeof params.order === "string" && params.order.toUpperCase() === "DESC"
40+
? -1
41+
: 1;
42+
const offset = typeof params.offset === "number" ? params.offset : 0;
43+
const limit = typeof params.limit === "number" ? params.limit : undefined;
44+
45+
const filteredMessages = sessionId
46+
? storedMessages.filter(
47+
(message) =>
48+
message.session_id === sessionId ||
49+
(sessionId === id && !message.session_id),
50+
)
51+
: storedMessages;
52+
const orderedMessages = [...filteredMessages].sort((a, b) => {
53+
const timeA = new Date(a.timestamp).getTime();
54+
const timeB = new Date(b.timestamp).getTime();
55+
if (Number.isNaN(timeA) || Number.isNaN(timeB)) return 0;
56+
return direction * (timeA - timeB);
57+
});
58+
59+
return orderedMessages.slice(
60+
offset,
61+
limit === undefined ? undefined : offset + limit,
62+
);
63+
};
64+
2965
export const getMessages = async (
3066
id?: string,
3167
params: Record<string, unknown> = {},
@@ -61,7 +97,7 @@ export const getMessages = async (
6197
}
6298

6399
return {
64-
data: JSON.parse(window.sessionStorage.getItem(id ?? "") || "[]"),
100+
data: getStoredMessages(id, params),
65101
};
66102
};
67103

@@ -91,10 +127,14 @@ export const useGetMessagesQuery: useQueryFunctionType<
91127
return { rows: data, columns };
92128
};
93129

94-
const queryResult = query(["useGetMessagesQuery", { id }], responseFn, {
95-
placeholderData: keepPreviousData,
96-
...options,
97-
});
130+
const queryResult = query(
131+
["useGetMessagesQuery", { id, mode, excludedFields, params }],
132+
responseFn,
133+
{
134+
placeholderData: keepPreviousData,
135+
...options,
136+
},
137+
);
98138

99139
return queryResult;
100140
};

0 commit comments

Comments
 (0)