mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 03:29:00 +08:00
Merge remote-tracking branch 'origin/api-memory' into temp-memory
This commit is contained in:
@ -73,11 +73,11 @@ jest.mock("@tanstack/react-query", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { mapMemoryApiToMemoryInfo } from "../mappers";
|
||||
import type { MemoryApiDTO } from "../types";
|
||||
import { useCreateMemory } from "../use-create-memory";
|
||||
import { useDeleteMemory } from "../use-delete-memory";
|
||||
import { useUpdateMemory } from "../use-update-memory";
|
||||
import { mapMemoryApiToMemoryInfo } from "../mappers";
|
||||
import type { MemoryApiDTO } from "../types";
|
||||
|
||||
describe("memories mutation hooks cache wiring", () => {
|
||||
const buildMemoryDto = (overrides?: Partial<MemoryApiDTO>): MemoryApiDTO => ({
|
||||
|
||||
@ -0,0 +1,140 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
type QueryFn = (ctx: { pageParam: number }) => Promise<unknown>;
|
||||
|
||||
type InfiniteQueryOptions = {
|
||||
enabled?: boolean;
|
||||
queryKey: readonly unknown[];
|
||||
queryFn: QueryFn;
|
||||
initialPageParam?: number;
|
||||
getNextPageParam?: (lastPage: any) => number | undefined;
|
||||
};
|
||||
|
||||
const useInfiniteQueryMock = jest.fn();
|
||||
|
||||
jest.mock("@tanstack/react-query", () => ({
|
||||
useInfiniteQuery: (options: InfiniteQueryOptions) =>
|
||||
useInfiniteQueryMock(options),
|
||||
}));
|
||||
|
||||
const apiGetMock = jest.fn();
|
||||
|
||||
jest.mock("@/controllers/API/api", () => ({
|
||||
api: {
|
||||
get: (...args: unknown[]) => apiGetMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/controllers/API/helpers/constants", () => ({
|
||||
getURL: () => "/api/v1/memories",
|
||||
}));
|
||||
|
||||
import { useGetMemorySessions } from "../use-get-memory-sessions";
|
||||
|
||||
describe("useGetMemorySessions", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useInfiniteQueryMock.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("disables the query when memoryId is empty", () => {
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "" }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
expect(opts.enabled).toBe(false);
|
||||
expect(apiGetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables the query when memoryId is provided", () => {
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "m1" }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
expect(opts.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sets initialPageParam to 1", () => {
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "m1" }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
expect(opts.initialPageParam).toBe(1);
|
||||
});
|
||||
|
||||
it("includes memoryId and default size in the query key", () => {
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "m1" }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
expect(opts.queryKey).toContain("m1");
|
||||
expect(opts.queryKey).toContain(50);
|
||||
});
|
||||
|
||||
it("uses custom size in the query key when provided", () => {
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "m1", size: 10 }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
expect(opts.queryKey).toContain(10);
|
||||
});
|
||||
|
||||
it("getNextPageParam returns next page when pages remain", () => {
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "m1" }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
const result = opts.getNextPageParam?.({ page: 1, pages: 3 });
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("getNextPageParam returns undefined when on the last page", () => {
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "m1" }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
const result = opts.getNextPageParam?.({ page: 3, pages: 3 });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when memoryId is missing but query is forced enabled", async () => {
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "" }, { enabled: true }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
await expect(opts.queryFn({ pageParam: 1 })).rejects.toThrow(
|
||||
"memoryId is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the correct URL with page and size query params", async () => {
|
||||
apiGetMock.mockResolvedValue({
|
||||
data: { items: [], total: 0, page: 1, size: 50, pages: 1 },
|
||||
});
|
||||
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "m1" }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
await opts.queryFn({ pageParam: 2 });
|
||||
|
||||
const calledUrl: string = apiGetMock.mock.calls[0]?.[0];
|
||||
expect(calledUrl).toContain("/m1/sessions");
|
||||
expect(calledUrl).toContain("page=2");
|
||||
expect(calledUrl).toContain("size=50");
|
||||
});
|
||||
|
||||
it("normalises the API response with safe defaults", async () => {
|
||||
apiGetMock.mockResolvedValue({ data: {} });
|
||||
|
||||
renderHook(() => useGetMemorySessions({ memoryId: "m1" }));
|
||||
|
||||
const opts = useInfiniteQueryMock.mock
|
||||
.calls[0]?.[0] as InfiniteQueryOptions;
|
||||
const result: any = await opts.queryFn({ pageParam: 1 });
|
||||
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.pages).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,15 @@
|
||||
/** Maximum retry attempts for memory read queries. */
|
||||
export const MEMORIES_RETRY_MAX_ATTEMPTS = 2;
|
||||
|
||||
const MEMORIES_RETRY_DELAY_MS = 1000;
|
||||
const MEMORIES_RETRY_MAX_DELAY_MS = 10_000;
|
||||
|
||||
/** Exponential backoff capped at 10 s, shared by all memory query and mutation hooks. */
|
||||
export const memoriesRetryDelay = (attemptIndex: number): number =>
|
||||
Math.min(
|
||||
MEMORIES_RETRY_DELAY_MS * 2 ** attemptIndex,
|
||||
MEMORIES_RETRY_MAX_DELAY_MS,
|
||||
);
|
||||
|
||||
/** Default page size for all memory infinite queries. */
|
||||
export const MEMORIES_PAGE_SIZE = 50;
|
||||
@ -27,7 +27,7 @@ export interface MemoryInfo {
|
||||
description?: string;
|
||||
kb_name: string;
|
||||
embedding_model: string;
|
||||
embedding_provider: string;
|
||||
embedding_provider?: string;
|
||||
is_active: boolean;
|
||||
total_messages_processed: number;
|
||||
sessions_count: number;
|
||||
@ -47,7 +47,7 @@ export interface MemoryDocumentItem {
|
||||
sender: string;
|
||||
session_id: string;
|
||||
timestamp: string;
|
||||
ingestion_job_id?: string;
|
||||
job_id?: string;
|
||||
ingestion_timestamp?: string;
|
||||
message_id: string;
|
||||
}
|
||||
|
||||
@ -3,12 +3,13 @@ import type { useMutationFunctionType } from "@/types/api";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import { UseRequestProcessor } from "../../services/request-processor";
|
||||
import { mapMemoryApiToMemoryInfo } from "./mappers";
|
||||
import { memoriesRetryDelay } from "./memoriesQueryConfig";
|
||||
import type {
|
||||
AddMessagesToMemoryParams,
|
||||
MemoryApiDTO,
|
||||
MemoryInfo,
|
||||
} from "./types";
|
||||
import { mapMemoryApiToMemoryInfo } from "./mappers";
|
||||
import { ensureRequiredParam } from "./validation";
|
||||
|
||||
export const useAddMessagesToMemory: useMutationFunctionType<
|
||||
@ -45,7 +46,12 @@ export const useAddMessagesToMemory: useMutationFunctionType<
|
||||
MemoryInfo,
|
||||
Error,
|
||||
AddMessagesToMemoryParams
|
||||
> = mutate(["useAddMessagesToMemory"], addMessagesFn, options);
|
||||
> = mutate(["useAddMessagesToMemory"], addMessagesFn, {
|
||||
// POST is not safe to retry by default (risk of duplicates).
|
||||
retry: false,
|
||||
retryDelay: memoriesRetryDelay,
|
||||
...options,
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type UseMutationOptions,
|
||||
type UseMutationResult,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import type { useMutationFunctionType } from "@/types/api";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import type { CreateMemoryPayload, MemoryApiDTO, MemoryInfo } from "./types";
|
||||
import { mapMemoryApiToMemoryInfo } from "./mappers";
|
||||
import { addMemoryToMemoriesCache } from "./memories-cache-helpers";
|
||||
import { memoriesRetryDelay } from "./memoriesQueryConfig";
|
||||
import type { CreateMemoryPayload, MemoryApiDTO, MemoryInfo } from "./types";
|
||||
|
||||
export const useCreateMemory: useMutationFunctionType<
|
||||
undefined,
|
||||
@ -57,8 +58,8 @@ export const useCreateMemory: useMutationFunctionType<
|
||||
userOnSettled?.(data, error, variables, onMutateResult, context);
|
||||
},
|
||||
// POST is not safe to retry by default (risk of duplicates).
|
||||
retry: restOptions.retry ?? false,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
retry: false,
|
||||
retryDelay: memoriesRetryDelay,
|
||||
});
|
||||
|
||||
return mutation;
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import {
|
||||
type UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type UseMutationOptions,
|
||||
} from "@tanstack/react-query";
|
||||
import type { useMutationFunctionType } from "@/types/api";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import type { DeleteMemoryParams } from "./types";
|
||||
import { removeMemoryFromMemoriesCache } from "./memories-cache-helpers";
|
||||
import { memoriesRetryDelay } from "./memoriesQueryConfig";
|
||||
import type { DeleteMemoryParams } from "./types";
|
||||
|
||||
export const useDeleteMemory: useMutationFunctionType<
|
||||
undefined,
|
||||
@ -22,31 +23,40 @@ export const useDeleteMemory: useMutationFunctionType<
|
||||
"mutationFn" | "mutationKey"
|
||||
>
|
||||
| undefined;
|
||||
const { onSettled: userOnSettled, ...restOptions } = typedOptions ?? {};
|
||||
const {
|
||||
onSuccess: userOnSuccess,
|
||||
onSettled: userOnSettled,
|
||||
...restOptions
|
||||
} = typedOptions ?? {};
|
||||
|
||||
const deleteMemoryFn = async (params: DeleteMemoryParams): Promise<void> => {
|
||||
await api.delete(`${getURL("MEMORIES")}/${params.memoryId}`);
|
||||
|
||||
// Avoid refetching a resource that no longer exists.
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["useGetMemory", params.memoryId],
|
||||
});
|
||||
queryClient.removeQueries({ queryKey: ["useGetMemory", params.memoryId] });
|
||||
|
||||
// Keep cached lists consistent without forcing a refetch.
|
||||
removeMemoryFromMemoriesCache(queryClient, params.memoryId);
|
||||
};
|
||||
|
||||
const mutation = useMutation<void, unknown, DeleteMemoryParams, unknown>({
|
||||
mutationKey: ["useDeleteMemory"],
|
||||
mutationFn: deleteMemoryFn,
|
||||
...restOptions,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
// Cancel in-flight fetches for the deleted resource so a 404 doesn't land after removal.
|
||||
queryClient.cancelQueries({
|
||||
queryKey: ["useGetMemory", variables.memoryId],
|
||||
});
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["useGetMemory", variables.memoryId],
|
||||
});
|
||||
|
||||
// Keep cached lists consistent without forcing a refetch.
|
||||
removeMemoryFromMemoriesCache(queryClient, variables.memoryId);
|
||||
|
||||
userOnSuccess?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
onSettled: (data, error, variables, onMutateResult, context) => {
|
||||
userOnSettled?.(data, error, variables, onMutateResult, context);
|
||||
},
|
||||
// DELETE is not safe to retry by default (may produce confusing 404s).
|
||||
retry: restOptions.retry ?? false,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
retry: false,
|
||||
retryDelay: memoriesRetryDelay,
|
||||
});
|
||||
|
||||
return mutation;
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
type InfiniteData,
|
||||
type UseInfiniteQueryOptions,
|
||||
type UseInfiniteQueryResult,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import type { GetMemoriesApiResponse, GetMemoriesParams } from "./types";
|
||||
import { mapGetMemoriesApiResponse } from "./mappers";
|
||||
import {
|
||||
MEMORIES_PAGE_SIZE,
|
||||
MEMORIES_RETRY_BASE_DELAY_MS,
|
||||
MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
MEMORIES_RETRY_MAX_DELAY_MS,
|
||||
} from "@/pages/FlowPage/components/MemoriesMainContent/MemoriesMainContent.constants";
|
||||
memoriesRetryDelay,
|
||||
} from "./memoriesQueryConfig";
|
||||
import type { GetMemoriesApiResponse, GetMemoriesParams } from "./types";
|
||||
|
||||
type MemoriesPage = ReturnType<typeof mapGetMemoriesApiResponse>;
|
||||
|
||||
@ -70,11 +69,7 @@ export const useGetMemories = (
|
||||
lastPage.page < lastPage.pages ? lastPage.page + 1 : undefined,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
retryDelay: (attemptIndex) =>
|
||||
Math.min(
|
||||
MEMORIES_RETRY_BASE_DELAY_MS * 2 ** attemptIndex,
|
||||
MEMORIES_RETRY_MAX_DELAY_MS,
|
||||
),
|
||||
retryDelay: memoriesRetryDelay,
|
||||
...(options ?? {}),
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,18 +1,22 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
type InfiniteData,
|
||||
type UseInfiniteQueryOptions,
|
||||
type UseInfiniteQueryResult,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import { MEMORIES_PAGE_SIZE } from "@/pages/FlowPage/components/MemoriesMainContent/MemoriesMainContent.constants";
|
||||
import {
|
||||
MEMORIES_PAGE_SIZE,
|
||||
MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
memoriesRetryDelay,
|
||||
} from "./memoriesQueryConfig";
|
||||
|
||||
export type MemorySessionMessageApiItem = {
|
||||
timestamp: string;
|
||||
sender: string;
|
||||
sender_name?: string;
|
||||
ingestion_job_id?: string;
|
||||
job_id?: string;
|
||||
ingestion_timestamp?: string;
|
||||
session_id: string;
|
||||
text: string;
|
||||
@ -104,7 +108,8 @@ export const useGetMemorySessionMessages = (
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.pages ? lastPage.page + 1 : undefined,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
retry: MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
retryDelay: memoriesRetryDelay,
|
||||
enabled: !!memoryId && !!sessionId,
|
||||
...(options ?? {}),
|
||||
});
|
||||
|
||||
@ -1,40 +1,98 @@
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import type { useQueryFunctionType } from "@/types/api";
|
||||
import {
|
||||
type InfiniteData,
|
||||
type UseInfiniteQueryOptions,
|
||||
type UseInfiniteQueryResult,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import { UseRequestProcessor } from "../../services/request-processor";
|
||||
import type { MemorySessionInfo } from "./types";
|
||||
import { ensureRequiredParam } from "./validation";
|
||||
import {
|
||||
MEMORIES_PAGE_SIZE,
|
||||
MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
memoriesRetryDelay,
|
||||
} from "./memoriesQueryConfig";
|
||||
|
||||
export interface GetMemorySessionsParams {
|
||||
memoryId: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const useGetMemorySessions: useQueryFunctionType<
|
||||
GetMemorySessionsParams,
|
||||
MemorySessionInfo[]
|
||||
> = (params, options?) => {
|
||||
const { query } = UseRequestProcessor();
|
||||
export type GetMemorySessionsApiResponse = {
|
||||
items: MemorySessionInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
const getSessionsFn = async (): Promise<MemorySessionInfo[]> => {
|
||||
ensureRequiredParam(params?.memoryId, "memoryId");
|
||||
type SessionsPage = GetMemorySessionsApiResponse;
|
||||
|
||||
const { data } = await api.get<{ items: MemorySessionInfo[] }>(
|
||||
`${getURL("MEMORIES")}/${params.memoryId}/sessions`,
|
||||
);
|
||||
const SESSIONS_INFINITE_QUERY_KEY = "useGetMemorySessionsInfinite";
|
||||
|
||||
return Array.isArray(data.items) ? data.items : [];
|
||||
type SessionsQueryKey = readonly [
|
||||
typeof SESSIONS_INFINITE_QUERY_KEY,
|
||||
string,
|
||||
number,
|
||||
];
|
||||
|
||||
export const useGetMemorySessions = (
|
||||
params: GetMemorySessionsParams,
|
||||
options?: Omit<
|
||||
UseInfiniteQueryOptions<
|
||||
SessionsPage,
|
||||
unknown,
|
||||
InfiniteData<SessionsPage, number>,
|
||||
SessionsQueryKey,
|
||||
number
|
||||
>,
|
||||
"queryKey" | "queryFn" | "initialPageParam" | "getNextPageParam"
|
||||
>,
|
||||
): UseInfiniteQueryResult<InfiniteData<SessionsPage, number>, unknown> => {
|
||||
const memoryId = params?.memoryId;
|
||||
const size = params?.size ?? MEMORIES_PAGE_SIZE;
|
||||
|
||||
const getSessionsPage = async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<SessionsPage> => {
|
||||
if (!memoryId) {
|
||||
throw new Error("memoryId is required");
|
||||
}
|
||||
|
||||
const baseUrl = `${getURL("MEMORIES")}/${memoryId}/sessions`;
|
||||
const url = new URL(baseUrl, window.location.origin);
|
||||
url.searchParams.set("page", String(pageParam));
|
||||
url.searchParams.set("size", String(size));
|
||||
|
||||
const res = await api.get<GetMemorySessionsApiResponse>(url.toString());
|
||||
|
||||
return {
|
||||
items: Array.isArray(res.data?.items) ? res.data.items : [],
|
||||
total: Number(res.data?.total ?? 0),
|
||||
page: Number(res.data?.page ?? pageParam),
|
||||
size: Number(res.data?.size ?? size),
|
||||
pages: Number(res.data?.pages ?? 1),
|
||||
};
|
||||
};
|
||||
|
||||
const queryResult: UseQueryResult<MemorySessionInfo[], Error> = query(
|
||||
["useGetMemorySessions", params?.memoryId],
|
||||
getSessionsFn,
|
||||
{
|
||||
enabled: !!params?.memoryId,
|
||||
refetchOnWindowFocus: false,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
|
||||
return queryResult;
|
||||
return useInfiniteQuery<
|
||||
SessionsPage,
|
||||
unknown,
|
||||
InfiniteData<SessionsPage, number>,
|
||||
SessionsQueryKey,
|
||||
number
|
||||
>({
|
||||
queryKey: [SESSIONS_INFINITE_QUERY_KEY, memoryId, size] as const,
|
||||
queryFn: getSessionsPage,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.pages ? lastPage.page + 1 : undefined,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
retryDelay: memoriesRetryDelay,
|
||||
enabled: !!memoryId,
|
||||
...(options ?? {}),
|
||||
});
|
||||
};
|
||||
|
||||
@ -3,9 +3,13 @@ import type { useQueryFunctionType } from "@/types/api";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import { UseRequestProcessor } from "../../services/request-processor";
|
||||
import type { GetMemoryParams, MemoryApiDTO, MemoryInfo } from "./types";
|
||||
import { mapMemoryApiToMemoryInfo } from "./mappers";
|
||||
import {
|
||||
MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
memoriesRetryDelay,
|
||||
} from "./memoriesQueryConfig";
|
||||
import { ensureRequiredParam } from "./validation";
|
||||
import type { GetMemoryParams, MemoryApiDTO, MemoryInfo } from "./types";
|
||||
|
||||
export const useGetMemory: useQueryFunctionType<GetMemoryParams, MemoryInfo> = (
|
||||
params,
|
||||
@ -28,6 +32,8 @@ export const useGetMemory: useQueryFunctionType<GetMemoryParams, MemoryInfo> = (
|
||||
{
|
||||
enabled: !!params?.memoryId,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
retryDelay: memoriesRetryDelay,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import {
|
||||
type UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type UseMutationOptions,
|
||||
} from "@tanstack/react-query";
|
||||
import type { useMutationFunctionType } from "@/types/api";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import type { MemoryApiDTO, MemoryInfo, UpdateMemoryParams } from "./types";
|
||||
import { mapMemoryApiToMemoryInfo } from "./mappers";
|
||||
import {
|
||||
MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
memoriesRetryDelay,
|
||||
} from "./memoriesQueryConfig";
|
||||
import { updateMemoryInMemoriesCache } from "./memories-cache-helpers";
|
||||
import type { MemoryApiDTO, MemoryInfo, UpdateMemoryParams } from "./types";
|
||||
|
||||
export const useUpdateMemory: useMutationFunctionType<
|
||||
undefined,
|
||||
@ -52,12 +56,12 @@ export const useUpdateMemory: useMutationFunctionType<
|
||||
>({
|
||||
mutationKey: ["useUpdateMemory"],
|
||||
mutationFn: updateMemoryFn,
|
||||
retry: MEMORIES_RETRY_MAX_ATTEMPTS,
|
||||
retryDelay: memoriesRetryDelay,
|
||||
...restOptions,
|
||||
onSettled: (data, error, variables, onMutateResult, context) => {
|
||||
userOnSettled?.(data, error, variables, onMutateResult, context);
|
||||
},
|
||||
retry: restOptions.retry ?? 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
|
||||
return mutation;
|
||||
|
||||
@ -102,7 +102,7 @@ export default function CreateMemoryModal({
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="memory-batch-size">Batch Size</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Messages to accumulate before ingestion triggers (min 1)
|
||||
Messages per ingestion batch (min 1)
|
||||
</span>
|
||||
<Input
|
||||
id="memory-batch-size"
|
||||
@ -180,7 +180,7 @@ export default function CreateMemoryModal({
|
||||
<BaseModal.Footer
|
||||
submit={{
|
||||
label: "Create Memory",
|
||||
icon: <ForwardedIconComponent name="Plus" className="mr-2 h-4 w-4" />,
|
||||
icon: <ForwardedIconComponent name="Plus" className="h-4 w-4" />,
|
||||
loading: createMemoryMutation.isPending,
|
||||
disabled:
|
||||
!name.trim() ||
|
||||
|
||||
@ -1,9 +1,3 @@
|
||||
export const SIDEBAR_SCROLL_THRESHOLD_PX = 160;
|
||||
export const KNOWLEDGE_BASE_SCROLL_THRESHOLD_PX = 240;
|
||||
export const AUTO_CAPTURE_DEBOUNCE_MS = 300;
|
||||
export const MEMORIES_PAGE_SIZE = 50;
|
||||
|
||||
/** Retry policy for memory infinite queries */
|
||||
export const MEMORIES_RETRY_BASE_DELAY_MS = 1000;
|
||||
export const MEMORIES_RETRY_MAX_DELAY_MS = 30000;
|
||||
export const MEMORIES_RETRY_MAX_ATTEMPTS = 2;
|
||||
|
||||
@ -44,8 +44,12 @@ jest.mock("../components/MemoriesSidebar", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const capturedMemoryDetailsProps: Record<string, unknown> = {};
|
||||
jest.mock("../components/MemoryDetails", () => ({
|
||||
MemoryDetails: () => <div>MemoryDetails</div>,
|
||||
MemoryDetails: (props: any) => {
|
||||
Object.assign(capturedMemoryDetailsProps, props);
|
||||
return <div>MemoryDetails</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../components/MemoryDocumentPanel", () => ({
|
||||
@ -64,6 +68,9 @@ jest.mock("@/modals/createMemoryModal", () => ({
|
||||
describe("MemoriesMainContent", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.keys(capturedMemoryDetailsProps).forEach(
|
||||
(k) => delete capturedMemoryDetailsProps[k],
|
||||
);
|
||||
mockedHookValue = {
|
||||
memories: [],
|
||||
filteredMemories: [],
|
||||
@ -84,6 +91,10 @@ describe("MemoriesMainContent", () => {
|
||||
deleteMutation: { mutate: jest.fn(), isPending: false },
|
||||
updateMemoryMutation: { isPending: false },
|
||||
handleToggleActive: jest.fn(),
|
||||
onRefresh: jest.fn(),
|
||||
fetchNextSessionsPage: jest.fn(),
|
||||
hasNextSessionsPage: false,
|
||||
isFetchingNextSessionsPage: false,
|
||||
createModalOpen: false,
|
||||
setCreateModalOpen: mockSetCreateModalOpen,
|
||||
fetchNextMessagesPage: jest.fn(),
|
||||
@ -134,4 +145,24 @@ describe("MemoriesMainContent", () => {
|
||||
fireEvent.click(screen.getByText("create-success"));
|
||||
expect(screen.getByText("selected:m-created")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes onRefresh, fetchNextSessionsPage and session pagination flags to MemoryDetails", () => {
|
||||
const onRefresh = jest.fn();
|
||||
const fetchNextSessionsPage = jest.fn();
|
||||
mockedHookValue.memory = { id: "m1" };
|
||||
mockedHookValue.onRefresh = onRefresh;
|
||||
mockedHookValue.fetchNextSessionsPage = fetchNextSessionsPage;
|
||||
mockedHookValue.hasNextSessionsPage = true;
|
||||
mockedHookValue.isFetchingNextSessionsPage = true;
|
||||
|
||||
render(<MemoriesMainContent />);
|
||||
fireEvent.click(screen.getByText("select-memory"));
|
||||
|
||||
expect(capturedMemoryDetailsProps.onRefresh).toBe(onRefresh);
|
||||
expect(capturedMemoryDetailsProps.fetchNextSessionsPage).toBe(
|
||||
fetchNextSessionsPage,
|
||||
);
|
||||
expect(capturedMemoryDetailsProps.hasNextSessionsPage).toBe(true);
|
||||
expect(capturedMemoryDetailsProps.isFetchingNextSessionsPage).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -45,7 +45,7 @@ export function MemoriesSidebar({
|
||||
onClick={onCreateMemory}
|
||||
disabled={!currentFlowId}
|
||||
>
|
||||
<IconComponent name="Plus" className="mr-1.5 h-3.5 w-3.5" />
|
||||
<IconComponent name="Plus" className="h-3.5 w-3.5" />
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
@ -75,18 +75,6 @@ export function MemoriesSidebar({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{memoriesSearch.trim() ? "No memories found" : "No memories yet"}
|
||||
</p>
|
||||
{!memoriesSearch.trim() && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCreateMemory}
|
||||
disabled={!currentFlowId}
|
||||
>
|
||||
<IconComponent name="Plus" className="mr-1.5 h-3.5 w-3.5" />
|
||||
Create Memory
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
import { formatDate } from "../helpers";
|
||||
import { MemoryDetailsProps } from "../types";
|
||||
import { MemoryDetailsHeader } from "./MemoryDetailsHeader";
|
||||
import { MemoryKnowledgeBaseSection } from "./MemoryKnowledgeBaseSection";
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
|
||||
export function MemoryDetails({
|
||||
memory,
|
||||
@ -17,7 +17,18 @@ export function MemoryDetails({
|
||||
handleOpenDocumentPanel,
|
||||
deleteMutation,
|
||||
handleToggleActive,
|
||||
onRefresh,
|
||||
fetchNextSessionsPage,
|
||||
hasNextSessionsPage,
|
||||
isFetchingNextSessionsPage,
|
||||
}: MemoryDetailsProps) {
|
||||
const pendingLabel =
|
||||
memory.batch_size > 1 ? "Pending (this batch)" : "Pending Messages";
|
||||
const pendingValue =
|
||||
memory.batch_size > 1
|
||||
? `${memory.pending_messages_count}/${memory.batch_size}`
|
||||
: memory.pending_messages_count;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MemoryDetailsHeader
|
||||
@ -27,24 +38,20 @@ export function MemoryDetails({
|
||||
setSelectedSession={setSelectedSession}
|
||||
deleteMutation={deleteMutation}
|
||||
handleToggleActive={handleToggleActive}
|
||||
onRefresh={onRefresh}
|
||||
fetchNextSessionsPage={fetchNextSessionsPage}
|
||||
hasNextSessionsPage={hasNextSessionsPage}
|
||||
isFetchingNextSessionsPage={isFetchingNextSessionsPage}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-auto p-4">
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<SummaryCard
|
||||
label="Messages Processed"
|
||||
label="Processed Messages"
|
||||
value={memory.total_messages_processed}
|
||||
icon="MessageSquare"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Pending Messages"
|
||||
value={
|
||||
memory.batch_size > 1
|
||||
? `${memory.pending_messages_count}/${memory.batch_size}`
|
||||
: memory.pending_messages_count
|
||||
}
|
||||
icon="Timer"
|
||||
/>
|
||||
<SummaryCard label={pendingLabel} value={pendingValue} icon="Timer" />
|
||||
<SummaryCard
|
||||
label="Last Generated"
|
||||
value={formatDate(memory.last_generated_at)}
|
||||
@ -70,7 +77,9 @@ export function MemoryDetails({
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
<span className="font-medium text-foreground">Batch Size:</span>{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Messages per batch:
|
||||
</span>{" "}
|
||||
{memory.batch_size}
|
||||
</span>
|
||||
</>
|
||||
@ -94,8 +103,6 @@ export function MemoryDetails({
|
||||
fetchNextMessagesPage={fetchNextMessagesPage}
|
||||
hasNextMessagesPage={hasNextMessagesPage}
|
||||
isFetchingNextMessagesPage={isFetchingNextMessagesPage}
|
||||
selectedSession={selectedSession}
|
||||
setSelectedSession={setSelectedSession}
|
||||
groupedBySession={groupedBySession}
|
||||
handleOpenDocumentPanel={handleOpenDocumentPanel}
|
||||
/>
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import IconComponent from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import DeleteConfirmationModal from "@/modals/deleteConfirmationModal";
|
||||
import type { MemoryDetailsHeaderProps } from "../types";
|
||||
|
||||
@ -10,7 +16,26 @@ export function MemoryDetailsHeader({
|
||||
setSelectedSession,
|
||||
deleteMutation,
|
||||
handleToggleActive,
|
||||
onRefresh,
|
||||
fetchNextSessionsPage,
|
||||
hasNextSessionsPage,
|
||||
isFetchingNextSessionsPage,
|
||||
}: MemoryDetailsHeaderProps) {
|
||||
const effectiveSession = (selectedSession ?? sessions?.[0] ?? "") as
|
||||
| string
|
||||
| null;
|
||||
|
||||
const handleSessionsScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
if (
|
||||
el.scrollHeight - el.scrollTop <= el.clientHeight * 2 &&
|
||||
hasNextSessionsPage &&
|
||||
!isFetchingNextSessionsPage
|
||||
) {
|
||||
fetchNextSessionsPage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-border bg-background px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@ -26,30 +51,89 @@ export function MemoryDetailsHeader({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
aria-label="Reload sessions and messages"
|
||||
>
|
||||
<IconComponent name="RefreshCw" className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{sessions && sessions.length > 0 && (
|
||||
<select
|
||||
aria-label="Session filter"
|
||||
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||
disabled={sessions.length <= 1}
|
||||
value={selectedSession ?? sessions[0] ?? ""}
|
||||
onChange={(e) => setSelectedSession(e.target.value)}
|
||||
>
|
||||
{sessions.map((sid) => (
|
||||
<option key={sid} value={sid}>
|
||||
{sid.length > 20 ? `${sid.slice(0, 20)}...` : sid}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Session filter"
|
||||
disabled={sessions.length <= 1 && !hasNextSessionsPage}
|
||||
className="w-[240px] justify-between px-3"
|
||||
>
|
||||
<span className="truncate">
|
||||
{effectiveSession && effectiveSession.length > 20
|
||||
? `${effectiveSession.slice(0, 20)}...`
|
||||
: (effectiveSession ?? "")}
|
||||
</span>
|
||||
<IconComponent
|
||||
name="ChevronDown"
|
||||
className="ml-2 h-4 w-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[240px] p-0">
|
||||
<div
|
||||
className="max-h-[240px] overflow-y-auto py-1"
|
||||
onScroll={handleSessionsScroll}
|
||||
>
|
||||
{sessions.map((sid) => {
|
||||
const isSelected = sid === effectiveSession;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={sid}
|
||||
className="flex items-center justify-between"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedSession(sid);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{sid}</span>
|
||||
<IconComponent
|
||||
name="Check"
|
||||
className={
|
||||
isSelected
|
||||
? "h-4 w-4 text-primary"
|
||||
: "h-4 w-4 opacity-0"
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{isFetchingNextSessionsPage && (
|
||||
<div className="py-1 text-center">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Loading…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={memory.is_active ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleToggleActive((prevIsActive) => !prevIsActive)}
|
||||
aria-pressed={memory.is_active}
|
||||
aria-label="Toggle auto-capture"
|
||||
className="gap-2"
|
||||
>
|
||||
Auto-capture: {memory.is_active ? "Enabled" : "Disabled"}
|
||||
<IconComponent
|
||||
name={memory.is_active ? "ToggleRight" : "ToggleLeft"}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
Auto-capture
|
||||
</Button>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
@ -65,7 +149,7 @@ export function MemoryDetailsHeader({
|
||||
size="sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<IconComponent name="Trash2" className="mr-1.5 h-3.5 w-3.5" />
|
||||
<IconComponent name="Trash2" className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</DeleteConfirmationModal>
|
||||
|
||||
@ -20,7 +20,6 @@ export function MemoryKnowledgeBaseSection({
|
||||
fetchNextMessagesPage,
|
||||
hasNextMessagesPage,
|
||||
isFetchingNextMessagesPage,
|
||||
setSelectedSession,
|
||||
groupedBySession,
|
||||
handleOpenDocumentPanel,
|
||||
}: MemoryKnowledgeBaseSectionProps) {
|
||||
@ -61,9 +60,8 @@ export function MemoryKnowledgeBaseSection({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-32 text-xs">Session</TableHead>
|
||||
<TableHead className="w-24 text-xs">Sender</TableHead>
|
||||
<TableHead className="w-40 text-xs">Ingestion Job</TableHead>
|
||||
<TableHead className="w-40 text-xs">Job ID</TableHead>
|
||||
<TableHead className="text-xs">Content</TableHead>
|
||||
<TableHead className="w-44 text-xs">
|
||||
Ingestion Timestamp
|
||||
@ -72,43 +70,22 @@ export function MemoryKnowledgeBaseSection({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from(groupedBySession.entries()).map(([sessionId, docs]) =>
|
||||
docs.map((doc, index) => (
|
||||
docs.map((doc, idx) => (
|
||||
<TableRow
|
||||
key={`${sessionId}-${doc.message_id}-${index}`}
|
||||
key={`${sessionId}-${doc.message_id}`}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
index === 0 && sessionId !== "(no session)"
|
||||
idx === 0 && sessionId !== "(no session)"
|
||||
? "border-t-2 border-t-border"
|
||||
: "",
|
||||
)}
|
||||
onClick={() => handleOpenDocumentPanel(doc)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{index === 0 ? (
|
||||
<span
|
||||
className="cursor-pointer truncate font-medium text-foreground hover:underline"
|
||||
title={sessionId}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedSession(sessionId);
|
||||
}}
|
||||
>
|
||||
{sessionId === "(no session)"
|
||||
? sessionId
|
||||
: sessionId.length > 12
|
||||
? `${sessionId.slice(0, 12)}...`
|
||||
: sessionId}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{doc.sender || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-40 truncate text-xs text-muted-foreground">
|
||||
{doc.ingestion_job_id || "-"}
|
||||
{doc.job_id || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md text-xs">
|
||||
<div className="line-clamp-2" title={doc.content}>
|
||||
@ -126,7 +103,7 @@ export function MemoryKnowledgeBaseSection({
|
||||
|
||||
{isFetchingNextMessagesPage && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={5} className="py-4">
|
||||
<TableCell colSpan={4} className="py-4">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<Loading size={16} className="text-muted-foreground" />
|
||||
Loading more...
|
||||
|
||||
@ -1,13 +1,46 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryDetailsHeader } from "../MemoryDetailsHeader";
|
||||
import type { MemoryDetailsHeaderProps } from "../../types";
|
||||
import type { MemoryInfo } from "@/controllers/API/queries/memories/types";
|
||||
import type { MemoryDetailsHeaderProps } from "../../types";
|
||||
import { MemoryDetailsHeader } from "../MemoryDetailsHeader";
|
||||
|
||||
jest.mock("@/components/common/genericIconComponent", () => ({
|
||||
__esModule: true,
|
||||
default: ({ name }: { name: string }) => <span>{name}</span>,
|
||||
}));
|
||||
|
||||
jest.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
DropdownMenuContent: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div className={className}>{children}</div>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: (e: Event) => void;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
role="menuitem"
|
||||
className={className}
|
||||
onClick={(e) => onSelect?.(e as unknown as Event)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/modals/deleteConfirmationModal", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
@ -52,6 +85,10 @@ describe("MemoryDetailsHeader", () => {
|
||||
setSelectedSession: jest.fn(),
|
||||
deleteMutation: { mutate: jest.fn(), isPending: false },
|
||||
handleToggleActive: jest.fn(),
|
||||
onRefresh: jest.fn(),
|
||||
fetchNextSessionsPage: jest.fn(),
|
||||
hasNextSessionsPage: false,
|
||||
isFetchingNextSessionsPage: false,
|
||||
};
|
||||
return { ...base, ...(overrides ?? {}) };
|
||||
};
|
||||
@ -64,7 +101,7 @@ describe("MemoryDetailsHeader", () => {
|
||||
expect(screen.getByText("Memory One")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Toggle auto-capture" }),
|
||||
).toHaveTextContent("Auto-capture: Enabled");
|
||||
).toHaveTextContent("Auto-capture");
|
||||
});
|
||||
|
||||
it("calls mutate handlers for actions", () => {
|
||||
@ -94,19 +131,151 @@ describe("MemoryDetailsHeader", () => {
|
||||
const props = makeProps({ sessions: ["session-1", "session-2"] });
|
||||
render(<MemoryDetailsHeader {...props} />);
|
||||
|
||||
expect(screen.getByLabelText("Session filter")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "session-1" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "session-2" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Session filter")).not.toBeDisabled();
|
||||
const sessionTrigger = screen.getByRole("button", {
|
||||
name: "Session filter",
|
||||
});
|
||||
expect(sessionTrigger).toBeInTheDocument();
|
||||
expect(sessionTrigger).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables the session selector when only one session exists", () => {
|
||||
const props = makeProps({ sessions: ["session-1"] });
|
||||
it("disables the session selector when only one session and no more pages", () => {
|
||||
const props = makeProps({
|
||||
sessions: ["session-1"],
|
||||
hasNextSessionsPage: false,
|
||||
});
|
||||
render(<MemoryDetailsHeader {...props} />);
|
||||
expect(screen.getByLabelText("Session filter")).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Session filter" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders the reload button with correct aria-label", () => {
|
||||
const props = makeProps();
|
||||
render(<MemoryDetailsHeader {...props} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Reload sessions and messages" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRefresh when the reload button is clicked", () => {
|
||||
const onRefresh = jest.fn();
|
||||
const props = makeProps({ onRefresh });
|
||||
render(<MemoryDetailsHeader {...props} />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Reload sessions and messages" }),
|
||||
);
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the RefreshCw icon inside the reload button", () => {
|
||||
const props = makeProps();
|
||||
render(<MemoryDetailsHeader {...props} />);
|
||||
const reloadBtn = screen.getByRole("button", {
|
||||
name: "Reload sessions and messages",
|
||||
});
|
||||
expect(reloadBtn).toHaveTextContent("RefreshCw");
|
||||
});
|
||||
|
||||
it("calls fetchNextSessionsPage on scroll when near bottom and more pages exist", () => {
|
||||
const fetchNextSessionsPage = jest.fn();
|
||||
const props = makeProps({
|
||||
sessions: ["s1", "s2", "s3"],
|
||||
hasNextSessionsPage: true,
|
||||
isFetchingNextSessionsPage: false,
|
||||
fetchNextSessionsPage,
|
||||
});
|
||||
const { container } = render(<MemoryDetailsHeader {...props} />);
|
||||
|
||||
const scrollDiv = container.querySelector(".overflow-y-auto");
|
||||
if (!scrollDiv) return;
|
||||
|
||||
Object.defineProperty(scrollDiv, "scrollHeight", {
|
||||
value: 400,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(scrollDiv, "scrollTop", {
|
||||
value: 200,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(scrollDiv, "clientHeight", {
|
||||
value: 240,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
fireEvent.scroll(scrollDiv);
|
||||
expect(fetchNextSessionsPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call fetchNextSessionsPage when already fetching next page", () => {
|
||||
const fetchNextSessionsPage = jest.fn();
|
||||
const props = makeProps({
|
||||
sessions: ["s1", "s2", "s3"],
|
||||
hasNextSessionsPage: true,
|
||||
isFetchingNextSessionsPage: true,
|
||||
fetchNextSessionsPage,
|
||||
});
|
||||
const { container } = render(<MemoryDetailsHeader {...props} />);
|
||||
|
||||
const scrollDiv = container.querySelector(".overflow-y-auto");
|
||||
if (!scrollDiv) return;
|
||||
|
||||
Object.defineProperty(scrollDiv, "scrollHeight", {
|
||||
value: 400,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(scrollDiv, "scrollTop", {
|
||||
value: 200,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(scrollDiv, "clientHeight", {
|
||||
value: 240,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
fireEvent.scroll(scrollDiv);
|
||||
expect(fetchNextSessionsPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call fetchNextSessionsPage when no more pages exist", () => {
|
||||
const fetchNextSessionsPage = jest.fn();
|
||||
const props = makeProps({
|
||||
sessions: ["s1", "s2"],
|
||||
hasNextSessionsPage: false,
|
||||
isFetchingNextSessionsPage: false,
|
||||
fetchNextSessionsPage,
|
||||
});
|
||||
const { container } = render(<MemoryDetailsHeader {...props} />);
|
||||
|
||||
const scrollDiv = container.querySelector(".overflow-y-auto");
|
||||
if (!scrollDiv) return;
|
||||
|
||||
Object.defineProperty(scrollDiv, "scrollHeight", {
|
||||
value: 400,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(scrollDiv, "scrollTop", {
|
||||
value: 300,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(scrollDiv, "clientHeight", {
|
||||
value: 240,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
fireEvent.scroll(scrollDiv);
|
||||
expect(fetchNextSessionsPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a loading indicator while fetching next page", () => {
|
||||
const props = makeProps({
|
||||
sessions: ["s1", "s2"],
|
||||
hasNextSessionsPage: true,
|
||||
isFetchingNextSessionsPage: true,
|
||||
});
|
||||
const { container } = render(<MemoryDetailsHeader {...props} />);
|
||||
|
||||
const scrollDiv = container.querySelector(".overflow-y-auto");
|
||||
expect(scrollDiv).not.toBeNull();
|
||||
expect(scrollDiv).toHaveTextContent("Loading");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryKnowledgeBaseSection } from "../MemoryKnowledgeBaseSection";
|
||||
import type { MemoryKnowledgeBaseSectionProps } from "../../types";
|
||||
import type { MemoryDocumentItem } from "@/controllers/API/queries/memories/types";
|
||||
import type { MemoryKnowledgeBaseSectionProps } from "../../types";
|
||||
import { MemoryKnowledgeBaseSection } from "../MemoryKnowledgeBaseSection";
|
||||
|
||||
jest.mock("@/components/common/genericIconComponent", () => ({
|
||||
__esModule: true,
|
||||
@ -20,7 +20,7 @@ describe("MemoryKnowledgeBaseSection", () => {
|
||||
message_id: "msg-1",
|
||||
session_id: "session-1",
|
||||
sender: "user",
|
||||
ingestion_job_id: "job-1",
|
||||
job_id: "job-1",
|
||||
ingestion_timestamp: "2025-01-01T10:00:01.000Z",
|
||||
content: "hello",
|
||||
timestamp: "2025-01-01T10:00:00.000Z",
|
||||
@ -37,8 +37,6 @@ describe("MemoryKnowledgeBaseSection", () => {
|
||||
fetchNextMessagesPage: jest.fn(),
|
||||
hasNextMessagesPage: false,
|
||||
isFetchingNextMessagesPage: false,
|
||||
selectedSession: null,
|
||||
setSelectedSession: jest.fn(),
|
||||
groupedBySession: new Map([["session-1", documents]]),
|
||||
handleOpenDocumentPanel: jest.fn(),
|
||||
};
|
||||
|
||||
@ -23,8 +23,7 @@ type MemoriesPageFixture = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture factories — provide required-field defaults so tests only specify
|
||||
// the fields they actually exercise.
|
||||
// Fixture factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMemoryInfo(
|
||||
@ -76,7 +75,7 @@ jest.mock("@/stores/alertStore", () => ({
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutable fixture state (reset in beforeEach; mutated in individual tests)
|
||||
// Mutable fixture state (reset in beforeEach)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let memories: MemoryInfo[] = [
|
||||
@ -89,9 +88,10 @@ let memories: MemoryInfo[] = [
|
||||
}),
|
||||
];
|
||||
|
||||
// Override to simulate multiple pages; undefined means use the default single-page shape.
|
||||
// Set in individual tests to simulate multiple pages; undefined = single page.
|
||||
let memoriesPages: MemoriesPageFixture[] | undefined;
|
||||
|
||||
const mockRefetchMemories = jest.fn();
|
||||
jest.mock("@/controllers/API/queries/memories/use-get-memories", () => ({
|
||||
useGetMemories: () => {
|
||||
const pages: MemoriesPageFixture[] = memoriesPages ?? [
|
||||
@ -102,6 +102,7 @@ jest.mock("@/controllers/API/queries/memories/use-get-memories", () => ({
|
||||
fetchNextPage: jest.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
refetch: mockRefetchMemories,
|
||||
};
|
||||
},
|
||||
}));
|
||||
@ -139,12 +140,26 @@ let memorySessionsData: MemorySessionInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockRefetchSessions = jest.fn();
|
||||
const mockFetchNextSessionsPage = jest.fn();
|
||||
jest.mock("@/controllers/API/queries/memories/use-get-memory-sessions", () => ({
|
||||
useGetMemorySessions: () => ({
|
||||
data: memorySessionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
items: memorySessionsData,
|
||||
total: memorySessionsData.length,
|
||||
page: 1,
|
||||
size: 50,
|
||||
pages: 1,
|
||||
},
|
||||
],
|
||||
pageParams: [1],
|
||||
},
|
||||
refetch: mockRefetchSessions,
|
||||
fetchNextPage: mockFetchNextSessionsPage,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -154,7 +169,7 @@ let messagesBySession: Record<string, MemorySessionMessageApiItem[]> = {
|
||||
timestamp: "2026-04-01T19:29:07",
|
||||
sender: "User",
|
||||
sender_name: "User",
|
||||
ingestion_job_id: "job-1",
|
||||
job_id: "job-1",
|
||||
ingestion_timestamp: "2026-04-02T20:51:06.951803",
|
||||
session_id: "s1",
|
||||
text: "Hello.",
|
||||
@ -166,7 +181,7 @@ let messagesBySession: Record<string, MemorySessionMessageApiItem[]> = {
|
||||
timestamp: "2026-04-01T19:29:08",
|
||||
sender: "Machine",
|
||||
sender_name: "AI",
|
||||
ingestion_job_id: "job-2",
|
||||
job_id: "job-2",
|
||||
ingestion_timestamp: "2026-04-02T20:51:06.951803",
|
||||
session_id: "s2",
|
||||
text: "Hi.",
|
||||
@ -175,6 +190,7 @@ let messagesBySession: Record<string, MemorySessionMessageApiItem[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
const mockRefetchMessages = jest.fn();
|
||||
jest.mock(
|
||||
"@/controllers/API/queries/memories/use-get-memory-session-messages",
|
||||
() => ({
|
||||
@ -190,6 +206,7 @@ jest.mock(
|
||||
fetchNextPage: jest.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
refetch: mockRefetchMessages,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@ -248,7 +265,7 @@ describe("useMemoriesData", () => {
|
||||
timestamp: "2026-04-01T19:29:07",
|
||||
sender: "User",
|
||||
sender_name: "User",
|
||||
ingestion_job_id: "job-1",
|
||||
job_id: "job-1",
|
||||
ingestion_timestamp: "2026-04-02T20:51:06.951803",
|
||||
session_id: "s1",
|
||||
text: "Hello.",
|
||||
@ -260,7 +277,7 @@ describe("useMemoriesData", () => {
|
||||
timestamp: "2026-04-01T19:29:08",
|
||||
sender: "Machine",
|
||||
sender_name: "AI",
|
||||
ingestion_job_id: "job-2",
|
||||
job_id: "job-2",
|
||||
ingestion_timestamp: "2026-04-02T20:51:06.951803",
|
||||
session_id: "s2",
|
||||
text: "Hi.",
|
||||
@ -402,8 +419,6 @@ describe("useMemoriesData", () => {
|
||||
expect(Array.from(result.current.groupedBySession.keys())).toEqual(["s2"]);
|
||||
});
|
||||
|
||||
// --- adversarial ---
|
||||
|
||||
it("deselects stale memory when the list comes back empty", () => {
|
||||
memories = [];
|
||||
const onSelectMemory = jest.fn();
|
||||
@ -455,12 +470,41 @@ describe("useMemoriesData", () => {
|
||||
result.current.setSelectedSession("ghost-session-xyz");
|
||||
});
|
||||
|
||||
// effectiveSessionId must resolve to one of the real sessions, never the ghost
|
||||
const keys = Array.from(result.current.groupedBySession.keys());
|
||||
expect(keys).not.toContain("ghost-session-xyz");
|
||||
expect(result.current.groupedBySession.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("exposes onRefresh as a function", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemoriesData({
|
||||
currentFlowId: "flow-1",
|
||||
selectedMemoryId: "m1",
|
||||
onSelectMemory: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(typeof result.current.onRefresh).toBe("function");
|
||||
});
|
||||
|
||||
it("onRefresh calls refetchMemories, refetchMemorySessions and refetchMessages", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemoriesData({
|
||||
currentFlowId: "flow-1",
|
||||
selectedMemoryId: "m1",
|
||||
onSelectMemory: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onRefresh();
|
||||
});
|
||||
|
||||
expect(mockRefetchMemories).toHaveBeenCalledTimes(1);
|
||||
expect(mockRefetchSessions).toHaveBeenCalled();
|
||||
expect(mockRefetchMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("flattens memories across multiple API pages into a single array", () => {
|
||||
memoriesPages = [
|
||||
{
|
||||
@ -488,6 +532,23 @@ describe("useMemoriesData", () => {
|
||||
);
|
||||
|
||||
expect(result.current.memories).toHaveLength(2);
|
||||
expect(result.current.memories.map((m) => m.id)).toEqual(["m1", "m2"]);
|
||||
expect(result.current.memories[0].id).toBe("m1");
|
||||
expect(result.current.memories[1].id).toBe("m2");
|
||||
});
|
||||
|
||||
it("exposes fetchNextSessionsPage, hasNextSessionsPage and isFetchingNextSessionsPage", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemoriesData({
|
||||
currentFlowId: "flow-1",
|
||||
selectedMemoryId: "m1",
|
||||
onSelectMemory: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.fetchNextSessionsPage).toBe(
|
||||
mockFetchNextSessionsPage,
|
||||
);
|
||||
expect(result.current.hasNextSessionsPage).toBe(false);
|
||||
expect(result.current.isFetchingNextSessionsPage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useMemoryDocuments } from "../useMemoryDocuments";
|
||||
|
||||
const mockFetchNextPage = jest.fn();
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
let mockPages: any[] = [];
|
||||
|
||||
jest.mock(
|
||||
"@/controllers/API/queries/memories/use-get-memory-session-messages",
|
||||
() => ({
|
||||
useGetMemorySessionMessages: () => ({
|
||||
data: { pages: mockPages, pageParams: [1] },
|
||||
isLoading: false,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const memorySessions = [
|
||||
{
|
||||
session_id: "s1",
|
||||
total_processed: 1,
|
||||
pending_count: 0,
|
||||
last_sync_at: "2026-04-01",
|
||||
id: "s1",
|
||||
memory_base_id: "m1",
|
||||
cursor_id: null,
|
||||
},
|
||||
{
|
||||
session_id: "s2",
|
||||
total_processed: 2,
|
||||
pending_count: 0,
|
||||
last_sync_at: "2026-03-01",
|
||||
id: "s2",
|
||||
memory_base_id: "m1",
|
||||
cursor_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe("useMemoryDocuments", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPages = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
timestamp: "2026-04-01T10:00:00",
|
||||
sender: "User",
|
||||
job_id: "job-1",
|
||||
ingestion_timestamp: "2026-04-01T11:00:00",
|
||||
session_id: "s1",
|
||||
text: "Hello world",
|
||||
},
|
||||
{
|
||||
timestamp: "2026-04-01T10:01:00",
|
||||
sender: "AI",
|
||||
job_id: "job-2",
|
||||
ingestion_timestamp: "2026-04-01T11:01:00",
|
||||
session_id: "s1",
|
||||
text: "Hi there",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
size: 50,
|
||||
pages: 1,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
it("exposes refetchMessages from the messages query", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }),
|
||||
);
|
||||
|
||||
expect(result.current.refetchMessages).toBe(mockRefetch);
|
||||
});
|
||||
|
||||
it("flattens all message pages into documents", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }),
|
||||
);
|
||||
|
||||
expect(result.current.docsData.documents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters out messages with no content", () => {
|
||||
mockPages = [
|
||||
{
|
||||
items: [
|
||||
{ timestamp: "t1", sender: "User", session_id: "s1", text: "Valid" },
|
||||
{ timestamp: "t2", sender: "AI", session_id: "s1", text: "" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }),
|
||||
);
|
||||
|
||||
expect(result.current.docsData.documents).toHaveLength(1);
|
||||
expect(result.current.docsData.documents![0].content).toBe("Valid");
|
||||
});
|
||||
|
||||
it("filters documents to the active sessionId", () => {
|
||||
mockPages = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
timestamp: "t1",
|
||||
sender: "User",
|
||||
session_id: "s1",
|
||||
text: "From s1",
|
||||
},
|
||||
{
|
||||
timestamp: "t2",
|
||||
sender: "User",
|
||||
session_id: "s2",
|
||||
text: "From s2",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }),
|
||||
);
|
||||
|
||||
expect(result.current.docsData.documents).toHaveLength(1);
|
||||
expect(result.current.docsData.documents![0].content).toBe("From s1");
|
||||
});
|
||||
|
||||
it("builds the sessions list from memorySessions", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }),
|
||||
);
|
||||
|
||||
expect(result.current.docsData.sessions).toEqual(["s1", "s2"]);
|
||||
});
|
||||
|
||||
it("deduplicates session IDs in the sessions list", () => {
|
||||
const dupSessions = [
|
||||
...memorySessions,
|
||||
{
|
||||
session_id: "s1",
|
||||
total_processed: 0,
|
||||
pending_count: 0,
|
||||
last_sync_at: "",
|
||||
id: "s1b",
|
||||
memory_base_id: "m1",
|
||||
cursor_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMemoryDocuments({
|
||||
memoryId: "m1",
|
||||
sessionId: "s1",
|
||||
memorySessions: dupSessions,
|
||||
}),
|
||||
);
|
||||
|
||||
const s1Count = result.current.docsData.sessions!.filter(
|
||||
(s) => s === "s1",
|
||||
).length;
|
||||
expect(s1Count).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,200 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useMemorySessionResolver } from "../useMemorySessionResolver";
|
||||
|
||||
const mockFetchNextPage = jest.fn();
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
let mockSessionPages: any[] = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
session_id: "s1",
|
||||
last_sync_at: "2026-04-02T00:00:00.000Z",
|
||||
total_processed: 5,
|
||||
pending_count: 0,
|
||||
},
|
||||
{
|
||||
session_id: "s2",
|
||||
last_sync_at: "2026-03-01T00:00:00.000Z",
|
||||
total_processed: 2,
|
||||
pending_count: 1,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
size: 50,
|
||||
pages: 1,
|
||||
},
|
||||
];
|
||||
let mockHasNextPage = false;
|
||||
let mockIsFetchingNextPage = false;
|
||||
|
||||
jest.mock("@/controllers/API/queries/memories/use-get-memory-sessions", () => ({
|
||||
useGetMemorySessions: () => ({
|
||||
data: { pages: mockSessionPages, pageParams: [1] },
|
||||
refetch: mockRefetch,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: mockHasNextPage,
|
||||
isFetchingNextPage: mockIsFetchingNextPage,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useMemorySessionResolver", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHasNextPage = false;
|
||||
mockIsFetchingNextPage = false;
|
||||
mockSessionPages = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
session_id: "s1",
|
||||
last_sync_at: "2026-04-02T00:00:00.000Z",
|
||||
total_processed: 5,
|
||||
pending_count: 0,
|
||||
},
|
||||
{
|
||||
session_id: "s2",
|
||||
last_sync_at: "2026-03-01T00:00:00.000Z",
|
||||
total_processed: 2,
|
||||
pending_count: 1,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
size: 50,
|
||||
pages: 1,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
it("flattens all pages into a single memorySessions array", () => {
|
||||
mockSessionPages = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
session_id: "s1",
|
||||
last_sync_at: "2026-04-02",
|
||||
total_processed: 0,
|
||||
pending_count: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
session_id: "s2",
|
||||
last_sync_at: "2026-03-01",
|
||||
total_processed: 0,
|
||||
pending_count: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMemorySessionResolver({ memoryId: "m1" }),
|
||||
);
|
||||
|
||||
expect(result.current.memorySessions).toHaveLength(2);
|
||||
expect(result.current.memorySessions[0].session_id).toBe("s1");
|
||||
expect(result.current.memorySessions[1].session_id).toBe("s2");
|
||||
});
|
||||
|
||||
it("returns empty memorySessions when pages are empty", () => {
|
||||
mockSessionPages = [];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMemorySessionResolver({ memoryId: "m1" }),
|
||||
);
|
||||
|
||||
expect(result.current.memorySessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes fetchNextSessionsPage from the query", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemorySessionResolver({ memoryId: "m1" }),
|
||||
);
|
||||
|
||||
expect(result.current.fetchNextSessionsPage).toBe(mockFetchNextPage);
|
||||
});
|
||||
|
||||
it("exposes hasNextSessionsPage from the query", () => {
|
||||
mockHasNextPage = true;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMemorySessionResolver({ memoryId: "m1" }),
|
||||
);
|
||||
|
||||
expect(result.current.hasNextSessionsPage).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes isFetchingNextSessionsPage from the query", () => {
|
||||
mockIsFetchingNextPage = true;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMemorySessionResolver({ memoryId: "m1" }),
|
||||
);
|
||||
|
||||
expect(result.current.isFetchingNextSessionsPage).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes refetchMemorySessions from the query", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemorySessionResolver({ memoryId: "m1" }),
|
||||
);
|
||||
|
||||
expect(result.current.refetchMemorySessions).toBe(mockRefetch);
|
||||
});
|
||||
|
||||
it("sets effectiveSessionId to the most recently synced session by default", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemorySessionResolver({ memoryId: "m1" }),
|
||||
);
|
||||
|
||||
expect(result.current.effectiveSessionId).toBe("s1");
|
||||
});
|
||||
|
||||
it("respects selectedSession when it exists in memorySessions", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemorySessionResolver({ memoryId: "m1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedSession("s2");
|
||||
});
|
||||
|
||||
expect(result.current.effectiveSessionId).toBe("s2");
|
||||
expect(result.current.selectedSession).toBe("s2");
|
||||
});
|
||||
|
||||
it("falls back to default session when selectedSession is not in the list", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMemorySessionResolver({ memoryId: "m1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedSession("nonexistent");
|
||||
});
|
||||
|
||||
expect(result.current.effectiveSessionId).toBe("s1");
|
||||
});
|
||||
|
||||
it("resets selectedSession when memoryId changes", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ memoryId }) => useMemorySessionResolver({ memoryId }),
|
||||
{ initialProps: { memoryId: "m1" } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedSession("s2");
|
||||
});
|
||||
expect(result.current.selectedSession).toBe("s2");
|
||||
|
||||
// Return empty pages for the new memory so the auto-select effect doesn't fire
|
||||
mockSessionPages = [];
|
||||
rerender({ memoryId: "m2" });
|
||||
|
||||
expect(result.current.selectedSession).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { UpdateMemoryParams } from "@/controllers/API/queries/memories/types";
|
||||
import type { MemoryInfo } from "@/controllers/API/queries/memories/types";
|
||||
import { AUTO_CAPTURE_DEBOUNCE_MS } from "../MemoriesMainContent.constants";
|
||||
import type {
|
||||
MemoryInfo,
|
||||
UpdateMemoryParams,
|
||||
} from "@/controllers/API/queries/memories/types";
|
||||
|
||||
type UpdateMemoryMutation = {
|
||||
mutate: (
|
||||
variables: UpdateMemoryParams,
|
||||
options?: { onSuccess?: () => void },
|
||||
options?: { onSuccess?: () => void; onError?: () => void },
|
||||
) => void;
|
||||
};
|
||||
|
||||
@ -99,16 +101,20 @@ export const useAutoCaptureDebouncedToggle = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const clearDraft = () => {
|
||||
setAutoCaptureDraft(null);
|
||||
draftIsActiveRef.current = null;
|
||||
};
|
||||
|
||||
updateMemoryMutation.mutate(
|
||||
{
|
||||
memoryId: memory.id,
|
||||
auto_capture: nextIsActive,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setAutoCaptureDraft(null);
|
||||
draftIsActiveRef.current = null;
|
||||
},
|
||||
onSuccess: clearDraft,
|
||||
// On failure the draft never resolved — reset so UI reflects server state.
|
||||
onError: clearDraft,
|
||||
},
|
||||
);
|
||||
autoCaptureTimerRef.current = null;
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { useGetMemories } from "@/controllers/API/queries/memories/use-get-memories";
|
||||
import { useGetMemory } from "@/controllers/API/queries/memories/use-get-memory";
|
||||
import { useDeleteMemory } from "@/controllers/API/queries/memories/use-delete-memory";
|
||||
import { useUpdateMemory } from "@/controllers/API/queries/memories/use-update-memory";
|
||||
import { UseMemoriesDataProps } from "../types";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
MemoryDocumentItem,
|
||||
MemoryInfo,
|
||||
} from "@/controllers/API/queries/memories/types";
|
||||
import { useMemorySessionResolver } from "./useMemorySessionResolver";
|
||||
import { useDeleteMemory } from "@/controllers/API/queries/memories/use-delete-memory";
|
||||
import { useGetMemories } from "@/controllers/API/queries/memories/use-get-memories";
|
||||
import { useGetMemory } from "@/controllers/API/queries/memories/use-get-memory";
|
||||
import { useUpdateMemory } from "@/controllers/API/queries/memories/use-update-memory";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { UseMemoriesDataProps } from "../types";
|
||||
import { useAutoCaptureDebouncedToggle } from "./useAutoCaptureDebouncedToggle";
|
||||
import { useMemoryDocuments } from "./useMemoryDocuments";
|
||||
import { AUTO_CAPTURE_DEBOUNCE_MS } from "../MemoriesMainContent.constants";
|
||||
import { extractApiErrorMessages } from "@/utils/apiError";
|
||||
import { useMemorySessionResolver } from "./useMemorySessionResolver";
|
||||
|
||||
const EMPTY_MEMORIES: MemoryInfo[] = [];
|
||||
|
||||
@ -35,6 +34,7 @@ export function useMemoriesData({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch: refetchMemories,
|
||||
} = useGetMemories(
|
||||
{ flowId: currentFlowId ?? undefined },
|
||||
{ enabled: !!currentFlowId },
|
||||
@ -83,7 +83,6 @@ export function useMemoriesData({
|
||||
{ memoryId: selectedMemoryId ?? "" },
|
||||
{
|
||||
enabled: !!selectedMemoryId,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
@ -92,6 +91,10 @@ export function useMemoriesData({
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
effectiveSessionId,
|
||||
refetchMemorySessions,
|
||||
fetchNextSessionsPage,
|
||||
hasNextSessionsPage,
|
||||
isFetchingNextSessionsPage,
|
||||
} = useMemorySessionResolver({ memoryId: selectedMemoryId });
|
||||
|
||||
const deleteMutation = useDeleteMemory({
|
||||
@ -118,7 +121,6 @@ export function useMemoriesData({
|
||||
useAutoCaptureDebouncedToggle({
|
||||
memory,
|
||||
updateMemoryMutation,
|
||||
debounceMs: AUTO_CAPTURE_DEBOUNCE_MS,
|
||||
});
|
||||
|
||||
const {
|
||||
@ -127,6 +129,7 @@ export function useMemoriesData({
|
||||
fetchNextMessagesPage,
|
||||
hasNextMessagesPage,
|
||||
isFetchingNextMessagesPage,
|
||||
refetchMessages,
|
||||
} = useMemoryDocuments({
|
||||
memoryId: selectedMemoryId,
|
||||
sessionId: effectiveSessionId,
|
||||
@ -163,6 +166,12 @@ export function useMemoriesData({
|
||||
return nextMemory;
|
||||
}, [memory, autoCaptureDraft, effectiveSessionId, memorySessions]);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
refetchMemories();
|
||||
refetchMemorySessions();
|
||||
refetchMessages();
|
||||
}, [refetchMemories, refetchMemorySessions, refetchMessages]);
|
||||
|
||||
const handleOpenDocumentPanel = (doc: MemoryDocumentItem) => {
|
||||
setSelectedDocument(doc);
|
||||
setDocumentPanelOpen(true);
|
||||
@ -173,13 +182,12 @@ export function useMemoriesData({
|
||||
const map = new Map<string, MemoryDocumentItem[]>();
|
||||
for (const doc of docsData.documents) {
|
||||
const sid = doc.session_id || "(no session)";
|
||||
if (effectiveSessionId && sid !== effectiveSessionId) continue;
|
||||
const list = map.get(sid) || [];
|
||||
list.push(doc);
|
||||
map.set(sid, list);
|
||||
}
|
||||
return map;
|
||||
}, [docsData, effectiveSessionId]);
|
||||
}, [docsData]);
|
||||
|
||||
return {
|
||||
memories,
|
||||
@ -207,6 +215,10 @@ export function useMemoriesData({
|
||||
deleteMutation,
|
||||
updateMemoryMutation,
|
||||
handleToggleActive,
|
||||
onRefresh,
|
||||
fetchNextSessionsPage,
|
||||
hasNextSessionsPage,
|
||||
isFetchingNextSessionsPage,
|
||||
createModalOpen,
|
||||
setCreateModalOpen,
|
||||
};
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useGetMemorySessionMessages } from "@/controllers/API/queries/memories/use-get-memory-session-messages";
|
||||
import type {
|
||||
MemoryDocumentItem,
|
||||
MemorySessionInfo,
|
||||
} from "@/controllers/API/queries/memories/types";
|
||||
import { useGetMemorySessionMessages } from "@/controllers/API/queries/memories/use-get-memory-session-messages";
|
||||
|
||||
type UseMemoryDocumentsArgs = {
|
||||
memoryId?: string | null;
|
||||
@ -24,6 +24,7 @@ export const useMemoryDocuments = ({
|
||||
fetchNextPage: fetchNextMessagesPage,
|
||||
hasNextPage: hasNextMessagesPage,
|
||||
isFetchingNextPage: isFetchingNextMessagesPage,
|
||||
refetch: refetchMessages,
|
||||
} = useGetMemorySessionMessages(
|
||||
{
|
||||
memoryId: memoryId ?? "",
|
||||
@ -40,16 +41,22 @@ export const useMemoryDocuments = ({
|
||||
|
||||
const rawDocuments: MemoryDocumentItem[] = pages
|
||||
.flatMap((p) => p?.items ?? [])
|
||||
.map((m) => {
|
||||
const ingestionJobId = String(m?.ingestion_job_id ?? "");
|
||||
.map((m, idx) => {
|
||||
const ingestionJobId = String(m?.job_id ?? "");
|
||||
const ingestionTimestamp = String(m?.ingestion_timestamp ?? "");
|
||||
const timestamp = String(m?.timestamp ?? "");
|
||||
const sender = String(m?.sender ?? "");
|
||||
const sessionIdFromMessage = String(m?.session_id ?? "");
|
||||
const messageId =
|
||||
ingestionJobId || timestamp || sender
|
||||
? [ingestionJobId, timestamp, sender].filter(Boolean).join(":")
|
||||
: "";
|
||||
// Include idx as a tiebreaker so messages sharing sender/timestamp/job are distinct.
|
||||
const messageId = [
|
||||
sessionIdFromMessage,
|
||||
ingestionJobId,
|
||||
timestamp,
|
||||
sender,
|
||||
String(idx),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":");
|
||||
|
||||
return {
|
||||
message_id: messageId,
|
||||
@ -57,7 +64,7 @@ export const useMemoryDocuments = ({
|
||||
sender,
|
||||
content: String(m?.text ?? ""),
|
||||
timestamp,
|
||||
...(ingestionJobId ? { ingestion_job_id: ingestionJobId } : {}),
|
||||
...(ingestionJobId ? { job_id: ingestionJobId } : {}),
|
||||
...(ingestionTimestamp
|
||||
? { ingestion_timestamp: ingestionTimestamp }
|
||||
: {}),
|
||||
@ -94,5 +101,6 @@ export const useMemoryDocuments = ({
|
||||
fetchNextMessagesPage,
|
||||
hasNextMessagesPage,
|
||||
isFetchingNextMessagesPage,
|
||||
refetchMessages,
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { MemorySessionInfo } from "@/controllers/API/queries/memories/types";
|
||||
import { useGetMemorySessions } from "@/controllers/API/queries/memories/use-get-memory-sessions";
|
||||
import { resolveDefaultSessionId } from "./memorySessionResolverHelpers";
|
||||
|
||||
@ -11,14 +12,23 @@ export const useMemorySessionResolver = ({
|
||||
}: UseMemorySessionResolverArgs) => {
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null);
|
||||
|
||||
const { data: memorySessions = [], refetch: refetchMemorySessions } =
|
||||
useGetMemorySessions(
|
||||
{ memoryId: memoryId ?? "" },
|
||||
{
|
||||
enabled: !!memoryId,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const {
|
||||
data: sessionsInfinite,
|
||||
refetch: refetchMemorySessions,
|
||||
fetchNextPage: fetchNextSessionsPage,
|
||||
hasNextPage: hasNextSessionsPage,
|
||||
isFetchingNextPage: isFetchingNextSessionsPage,
|
||||
} = useGetMemorySessions(
|
||||
{ memoryId: memoryId ?? "" },
|
||||
{
|
||||
enabled: !!memoryId,
|
||||
},
|
||||
);
|
||||
|
||||
const memorySessions = useMemo<MemorySessionInfo[]>(() => {
|
||||
const pages = sessionsInfinite?.pages ?? [];
|
||||
return pages.flatMap((p) => p?.items ?? []);
|
||||
}, [sessionsInfinite]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSession(null);
|
||||
@ -61,5 +71,9 @@ export const useMemorySessionResolver = ({
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
effectiveSessionId,
|
||||
refetchMemorySessions,
|
||||
fetchNextSessionsPage,
|
||||
hasNextSessionsPage,
|
||||
isFetchingNextSessionsPage,
|
||||
};
|
||||
};
|
||||
|
||||
@ -2,11 +2,11 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import Loading from "@/components/ui/loading";
|
||||
import CreateMemoryModal from "@/modals/createMemoryModal";
|
||||
import useFlowsManagerStore from "@/stores/flowsManagerStore";
|
||||
import { useMemoriesData } from "./hooks/useMemoriesData";
|
||||
import { NoMemorySelected } from "./components/NoMemorySelected";
|
||||
import { MemoriesSidebar } from "./components/MemoriesSidebar";
|
||||
import { MemoryDetails } from "./components/MemoryDetails";
|
||||
import { MemoryDocumentPanel } from "./components/MemoryDocumentPanel";
|
||||
import { NoMemorySelected } from "./components/NoMemorySelected";
|
||||
import { useMemoriesData } from "./hooks/useMemoriesData";
|
||||
|
||||
export default function MemoriesMainContent() {
|
||||
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
|
||||
@ -48,6 +48,10 @@ export default function MemoriesMainContent() {
|
||||
handleOpenDocumentPanel,
|
||||
deleteMutation,
|
||||
handleToggleActive,
|
||||
onRefresh,
|
||||
fetchNextSessionsPage,
|
||||
hasNextSessionsPage,
|
||||
isFetchingNextSessionsPage,
|
||||
createModalOpen,
|
||||
setCreateModalOpen,
|
||||
} = useMemoriesData({
|
||||
@ -95,6 +99,10 @@ export default function MemoriesMainContent() {
|
||||
handleOpenDocumentPanel={handleOpenDocumentPanel}
|
||||
deleteMutation={deleteMutation}
|
||||
handleToggleActive={handleToggleActive}
|
||||
onRefresh={onRefresh}
|
||||
fetchNextSessionsPage={fetchNextSessionsPage}
|
||||
hasNextSessionsPage={hasNextSessionsPage}
|
||||
isFetchingNextSessionsPage={isFetchingNextSessionsPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -39,6 +39,10 @@ export type MemoryDetailsProps = {
|
||||
handleOpenDocumentPanel: (doc: MemoryDocumentItem) => void;
|
||||
deleteMutation: MemoryActionMutation;
|
||||
handleToggleActive: (nextIsActive: NextIsActive) => void;
|
||||
onRefresh: () => void;
|
||||
fetchNextSessionsPage: () => void;
|
||||
hasNextSessionsPage?: boolean;
|
||||
isFetchingNextSessionsPage?: boolean;
|
||||
};
|
||||
|
||||
export type MemoriesSidebarProps = {
|
||||
@ -71,8 +75,6 @@ export type MemoryKnowledgeBaseSectionProps = {
|
||||
fetchNextMessagesPage: () => void;
|
||||
hasNextMessagesPage?: boolean;
|
||||
isFetchingNextMessagesPage?: boolean;
|
||||
selectedSession: string | null;
|
||||
setSelectedSession: (value: string | null) => void;
|
||||
groupedBySession: Map<string, MemoryDocumentItem[]>;
|
||||
handleOpenDocumentPanel: (doc: MemoryDocumentItem) => void;
|
||||
};
|
||||
@ -84,4 +86,8 @@ export type MemoryDetailsHeaderProps = {
|
||||
setSelectedSession: (value: string | null) => void;
|
||||
deleteMutation: MemoryActionMutation;
|
||||
handleToggleActive: (nextIsActive: NextIsActive) => void;
|
||||
onRefresh: () => void;
|
||||
fetchNextSessionsPage: () => void;
|
||||
hasNextSessionsPage?: boolean;
|
||||
isFetchingNextSessionsPage?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user