fix: deduplicate workbench requests and remove unused map paths
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SWRConfig } from "swr";
|
||||
const harness = vi.hoisted(() => ({
|
||||
api: null as any,
|
||||
recovery: 0,
|
||||
flow: vi.fn(),
|
||||
task: vi.fn(),
|
||||
check: vi.fn()
|
||||
}));
|
||||
vi.mock("@/features/agent", async (original) => ({
|
||||
...(await original<any>()),
|
||||
createAgentApiClient: () => harness.api
|
||||
}));
|
||||
vi.mock("@/features/agent/hooks/use-agent-connection", () => ({
|
||||
useAgentConnection: () => ({
|
||||
connectionStatus: "online",
|
||||
recoveryVersion: harness.recovery,
|
||||
recheck: harness.check
|
||||
})
|
||||
}));
|
||||
vi.mock("@/features/workbench/api/realtime-flow-client", () => ({
|
||||
fetchRealtimePipeFlows: harness.flow
|
||||
}));
|
||||
vi.mock("@/features/workbench/api/operational-task-client", async (original) => ({
|
||||
...(await original<any>()),
|
||||
fetchOperationalTask: harness.task,
|
||||
fetchOperationalTaskNotifications: async () => []
|
||||
}));
|
||||
import { useWorkbenchRuntimeData } from "@/features/workbench/hooks/use-workbench-runtime-data";
|
||||
import { useWorkbenchAgent } from "@/features/workbench/hooks/use-workbench-agent";
|
||||
import { useOperationalTasks } from "@/features/workbench/hooks/use-operational-tasks";
|
||||
import { WorkbenchSessionProvider } from "@/features/workbench/hooks/workbench-session-provider";
|
||||
import { useAgentMessageInbox } from "@/features/agent/hooks/use-agent-message-inbox";
|
||||
const access: any = {
|
||||
project: { project_id: "p", gs_workspace: "tjwater_next", status: "active" },
|
||||
getAccessToken: async () => "test"
|
||||
};
|
||||
const sync: any = {
|
||||
snapshot: null,
|
||||
events: [],
|
||||
status: "live",
|
||||
error: null,
|
||||
detailResetGeneration: 0,
|
||||
acknowledgeEventsThrough: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
subscribePresentationEvents: () => () => {}
|
||||
};
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
harness.flow.mockReset();
|
||||
harness.task.mockReset();
|
||||
harness.recovery = 0;
|
||||
});
|
||||
function wrapperFactory() {
|
||||
const cache = new Map();
|
||||
return ({ children }: any) => (
|
||||
<WorkbenchSessionProvider owner={access}>
|
||||
<SWRConfig
|
||||
value={{ provider: () => cache, revalidateOnFocus: false, shouldRetryOnError: false }}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
</WorkbenchSessionProvider>
|
||||
);
|
||||
}
|
||||
it("keeps one flow request and one polling timer when visibility changes during a request", async () => {
|
||||
vi.useFakeTimers();
|
||||
let visibility = "visible";
|
||||
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility as any);
|
||||
const done: Array<() => void> = [];
|
||||
harness.flow.mockImplementation(
|
||||
() => new Promise((resolve) => done.push(() => resolve({ records: [], observedAt: null })))
|
||||
);
|
||||
renderHook(() => useWorkbenchRuntimeData(access, sync));
|
||||
expect(harness.flow).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
visibility = "hidden";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
visibility = "visible";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(harness.flow).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
done[0]();
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
});
|
||||
expect(harness.flow).toHaveBeenCalledTimes(2);
|
||||
await act(async () => {
|
||||
done[1]();
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
});
|
||||
expect(harness.flow).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
it("queues new task events without cancelling or repeating the current detail request", async () => {
|
||||
const resolve: Array<(value: unknown) => void> = [];
|
||||
harness.task.mockImplementation(() => new Promise((done) => resolve.push(done)));
|
||||
const event = (seq: number, id: string) => ({
|
||||
event_seq: seq,
|
||||
topic: "operational.progress.changed",
|
||||
aggregate_type: "operational_task",
|
||||
aggregate_id: id,
|
||||
metadata: { task_id: id }
|
||||
});
|
||||
const first = event(1, "task-a");
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ events }) => useOperationalTasks(access, { ...sync, events }),
|
||||
{ initialProps: { events: [first] } }
|
||||
);
|
||||
rerender({ events: [first, event(2, "task-b")] });
|
||||
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a"]);
|
||||
expect(harness.task.mock.calls[0][2].aborted).toBe(false);
|
||||
await act(async () => {
|
||||
resolve[0]({
|
||||
task_id: "task-a",
|
||||
title: "A",
|
||||
trigger: { kind: "manual" },
|
||||
source: { kind: "operator" },
|
||||
task_type: "event_investigation",
|
||||
evidence: [],
|
||||
updated_at: "2026-11-09T00:00:00Z"
|
||||
});
|
||||
});
|
||||
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a", "task-b"]);
|
||||
await act(async () => {
|
||||
resolve[1]({
|
||||
task_id: "task-b",
|
||||
title: "B",
|
||||
trigger: { kind: "manual" },
|
||||
source: { kind: "operator" },
|
||||
task_type: "event_investigation",
|
||||
evidence: [],
|
||||
updated_at: "2026-11-09T00:00:00Z"
|
||||
});
|
||||
});
|
||||
expect(result.current.tasks.map((task) => task.task_id).sort()).toEqual(["task-a", "task-b"]);
|
||||
expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(2);
|
||||
unmount();
|
||||
});
|
||||
it("refreshes once per health recovery and uses normal loads on session changes", async () => {
|
||||
const api: any = {
|
||||
getModels: vi.fn(async () => ({ models: [], defaultModel: "" })),
|
||||
getUiRegistry: vi.fn(async () => ({})),
|
||||
getFrontendActionRegistry: vi.fn(async () => ({})),
|
||||
listSessionPage: vi.fn(async () => ({ sessions: [], hasMore: false, nextCursor: null })),
|
||||
listTopics: vi.fn(async () => []),
|
||||
listInteractionRequests: vi.fn(async () => ({ items: [] })),
|
||||
listResponseInputs: vi.fn(async () => []),
|
||||
listAgentMessages: vi.fn(async () => ({ items: [], next_cursor: null })),
|
||||
streamAgentMessages: vi.fn(
|
||||
(_id: any, options: any) =>
|
||||
new Promise<void>((resolve) =>
|
||||
options.signal.addEventListener("abort", () => resolve(), { once: true })
|
||||
)
|
||||
),
|
||||
loadSession: vi.fn(async (id: string) => ({
|
||||
id,
|
||||
sessionId: id,
|
||||
title: id,
|
||||
purpose: "ordinary_chat",
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
runStatus: "completed",
|
||||
isStreaming: false,
|
||||
isTitleManuallyEdited: false
|
||||
}))
|
||||
};
|
||||
harness.api = api;
|
||||
const { result, rerender } = renderHook(
|
||||
() =>
|
||||
useWorkbenchAgent({
|
||||
getAccessToken: access,
|
||||
onUiEnvelope: () => {},
|
||||
onFrontendAction: async () => ({})
|
||||
}),
|
||||
{ wrapper: wrapperFactory() }
|
||||
);
|
||||
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
await result.current.loadHistorySession("a");
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "a").length).toBe(1)
|
||||
);
|
||||
harness.recovery = 1;
|
||||
rerender();
|
||||
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(2));
|
||||
const before = api.getModels.mock.calls.length;
|
||||
await act(async () => {
|
||||
await result.current.loadHistorySession("b");
|
||||
});
|
||||
await waitFor(() => expect(api.getModels.mock.calls.length).toBe(before));
|
||||
await waitFor(() =>
|
||||
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "b").length).toBe(1)
|
||||
);
|
||||
const pending = api.listResponseInputs.mock.calls.filter((c: any) => c[0] === "b").length;
|
||||
expect(pending).toBe(1);
|
||||
});
|
||||
it("loads the inbox once when the stream is ready and refreshes on later notifications", async () => {
|
||||
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
|
||||
const stream = vi.fn((_id: any, options: any) => {
|
||||
options.onMessage();
|
||||
return new Promise<void>((resolve) =>
|
||||
options.signal.addEventListener("abort", () => resolve(), { once: true })
|
||||
);
|
||||
});
|
||||
const api = { listAgentMessages: list, streamAgentMessages: stream } as any;
|
||||
renderHook(() => useAgentMessageInbox(api, "s"), { wrapper: wrapperFactory() });
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
stream.mock.calls[0][1].onMessage();
|
||||
});
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("keeps newer task changes and does not revive a task deleted during a read", async () => {
|
||||
const complete: Array<(value: unknown) => void> = [];
|
||||
harness.task.mockImplementation(() => new Promise((resolve) => complete.push(resolve)));
|
||||
const event = (seq: number, deleted = false) => ({
|
||||
event_seq: seq,
|
||||
topic: "operational.task.changed",
|
||||
aggregate_type: "operational_task",
|
||||
aggregate_id: "a",
|
||||
metadata: { task_id: "a", ...(deleted ? { change: "deleted" } : {}) }
|
||||
});
|
||||
const a = event(1);
|
||||
const { result, rerender } = renderHook(
|
||||
({ events }) => useOperationalTasks(access, { ...sync, events }),
|
||||
{
|
||||
initialProps: { events: [a] }
|
||||
}
|
||||
);
|
||||
rerender({ events: [a, event(2)] });
|
||||
const task = {
|
||||
task_id: "a",
|
||||
title: "A",
|
||||
trigger: { kind: "manual" },
|
||||
source: { kind: "operator" },
|
||||
task_type: "event_investigation",
|
||||
evidence: [],
|
||||
updated_at: "2026-11-09T00:00:00Z"
|
||||
};
|
||||
await act(async () => {
|
||||
complete[0](task);
|
||||
});
|
||||
expect(harness.task).toHaveBeenCalledTimes(2);
|
||||
rerender({ events: [a, event(2), event(3, true)] });
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
await act(async () => {
|
||||
complete[1](task);
|
||||
});
|
||||
await waitFor(() => expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(3));
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
expect(harness.task).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("loads the initial inbox even when its stream cannot connect", async () => {
|
||||
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
|
||||
const api = {
|
||||
listAgentMessages: list,
|
||||
streamAgentMessages: vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
})
|
||||
} as any;
|
||||
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
|
||||
wrapper: wrapperFactory()
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads the inbox when its stream remains pending without a first frame", async () => {
|
||||
vi.useFakeTimers();
|
||||
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
|
||||
const api = {
|
||||
listAgentMessages: list,
|
||||
streamAgentMessages: vi.fn(
|
||||
(_id: string, options: { signal: AbortSignal }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
options.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
})
|
||||
)
|
||||
} as any;
|
||||
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
|
||||
wrapper: wrapperFactory()
|
||||
});
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
});
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
Reference in New Issue
Block a user