Files
kandev/apps/web/hooks/use-plan-panel-auto-open.test.ts
T
José Almeida 47f875a61e fix(web): plan panel misses agent updates emitted before WS connects (#749)
* fix(web): plan panel misses agent updates emitted before WS connects

Agent auto-start can write a plan via MCP within ~100 ms of task creation,
faster than the browser opens its WS connection — the `task_plan.created`
broadcast is delivered to zero clients and the Plan tab never appears
because `useTaskPlan` only fetches once the panel is mounted (chicken-egg).

Eagerly fetch the plan from `usePlanPanelAutoOpen` once the WS is
connected so the store is populated regardless of the panel's mount
state. Also acknowledge the plan when the panel is restored from a saved
layout with no `lastSeen` recorded — that's a page-reload hydrate, not a
fresh agent update, so a stale indicator should not flash.

* test(web): cover the new eager-fetch + reload-acknowledge branches

Mock the new store selectors and `getTaskPlan` API in
`use-plan-panel-auto-open.test.ts`, restore the original `panelExists →
return` short-circuit (the previous version slipped through to
`addPlanPanel` on a live update against an open panel), and add three
cases: eager-fetch on cold load, fetch gated on WS connect, and the
page-reload acknowledge.

* fix(web): guard the eager plan fetch against an infinite retry loop

`isLoading` flipping during the fetch was re-triggering the effect via
its own dep update — on persistent failure it would keep firing because
`isLoaded` never flips. Switch to a ref-keyed attempt set so each task
is fetched at most once per WS-connected session, and clear the set on
WS reconnect so transient failures still recover.

Also drop the redundant `activeTaskId` guard inside the auto-open
effect (`plan` is already derived from `activeTaskId`) and clarify the
catch-block comment.

Addresses Greptile P1 + Claude review suggestions on PR #749.

* test(web): cover the disconnect→reconnect retry + tighten code comments

Address Claude review on PR #749:
- attemptedRef comment now reflects what the clear actually does (clears
  all task IDs on WS disconnect, not per-task on navigation).
- Catch comment points at the disconnect/reconnect effect as the actual
  retry trigger instead of the misleading "useTaskPlan retries on panel
  mount" claim.
- Reload-acknowledge comment is honest about the lastSeen ambiguity:
  optimistic suppression of the indicator flash, with live-update path
  re-arming after a post-reload update.
- Added a regression test for the disconnect → reconnect → re-fetch
  path, which the previous test suite did not exercise.

* docs(web): clarify reload-acknowledge comment to match the actual control flow

* test(web): split test file under the 100-line per-function lint cap

The new eager-fetch + reconnect-retry tests pushed the top-level
describe callback to 114 lines, tripping max-lines-per-function. Split
into a sibling describe for the eager-fetch scope.

* fix(web): don't overwrite a WS-delivered plan with a stale null fetch

The HTTP fetch and the WS \`task_plan.created\` event can resolve in
either order on a fast auto-start: when the fetch was issued first
(server didn't have the plan yet, returns null) and the WS event arrives
mid-flight, the unconditional \`setTaskPlan(taskId, null)\` would clobber
the real plan that just landed. Read the live store state inside the
\`then\` and skip the write when null overlaps with a populated plan.

Adds a regression test for the race.

* docs(web): fix the disconnect/reconnect comment wording

Effect fires on disconnect, not reconnect — wording was inverted.

* fix(web): also guard the eager fetch against stale non-null overwrites

CodeRabbit caught a residual race: when a newer plan lands via WS while
the HTTP fetch is in flight, an older non-null HTTP response could
still clobber it (regressing updated_at + content). Compare timestamps
and skip the write when the live store entry is already at-or-newer.

Adds a regression test, splits the test file into a third sibling
describe so each callback stays under the 100-line lint cap.

* style(web): apply prettier formatting
2026-05-01 00:19:50 +01:00

271 lines
8.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import type { TaskPlan } from "@/lib/types/http";
const mockAddPlanPanel = vi.fn();
const mockGetPanel = vi.fn();
const mockSetTaskPlan = vi.fn();
const mockSetTaskPlanLoading = vi.fn();
const mockMarkTaskPlanSeen = vi.fn();
const mockGetTaskPlan = vi.fn();
let mockActiveTaskId: string | null = "task-1";
let mockPlan: TaskPlan | null = null;
let mockLastSeen: string | undefined = undefined;
let mockIsLoaded = true;
let mockConnectionStatus = "connected";
let mockIsRestoringLayout = false;
let mockApi: { getPanel: typeof mockGetPanel } | null = { getPanel: mockGetPanel };
function buildState() {
return {
tasks: { activeTaskId: mockActiveTaskId },
taskPlans: {
byTaskId: mockActiveTaskId && mockPlan ? { [mockActiveTaskId]: mockPlan } : {},
loadedByTaskId: mockActiveTaskId ? { [mockActiveTaskId]: mockIsLoaded } : {},
lastSeenUpdatedAtByTaskId:
mockActiveTaskId && mockLastSeen !== undefined ? { [mockActiveTaskId]: mockLastSeen } : {},
},
connection: { status: mockConnectionStatus },
setTaskPlan: mockSetTaskPlan,
setTaskPlanLoading: mockSetTaskPlanLoading,
markTaskPlanSeen: mockMarkTaskPlanSeen,
};
}
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) => selector(buildState()),
useAppStoreApi: () => ({ getState: () => buildState() }),
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
api: mockApi,
isRestoringLayout: mockIsRestoringLayout,
addPlanPanel: mockAddPlanPanel,
}),
}));
vi.mock("@/lib/api/domains/plan-api", () => ({
getTaskPlan: (...args: unknown[]) => mockGetTaskPlan(...args),
}));
import { usePlanPanelAutoOpen } from "./use-plan-panel-auto-open";
const TS = "2026-04-20T00:00:00Z";
const TS_LATER = "2026-04-20T01:00:00Z";
function agentPlan(updated_at = TS): TaskPlan {
return {
id: "plan-1",
task_id: "task-1",
title: "Plan",
content: "# Plan",
created_by: "agent",
created_at: TS,
updated_at,
};
}
describe("usePlanPanelAutoOpen", () => {
beforeEach(() => {
vi.clearAllMocks();
mockActiveTaskId = "task-1";
mockPlan = agentPlan();
mockLastSeen = undefined;
mockIsLoaded = true;
mockConnectionStatus = "connected";
mockIsRestoringLayout = false;
mockApi = { getPanel: mockGetPanel };
mockGetPanel.mockReturnValue(null);
mockGetTaskPlan.mockResolvedValue(null);
});
it("opens plan panel for unseen agent plan", () => {
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).toHaveBeenCalledWith({ quiet: true, inCenter: true });
});
it("does not open when isRestoringLayout is true", () => {
mockIsRestoringLayout = true;
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not open when api is null", () => {
mockApi = null;
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not open when plan created_by is user", () => {
mockPlan = { ...agentPlan(), created_by: "user" };
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not open when plan is already seen (lastSeen === updated_at)", () => {
mockLastSeen = TS;
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("opens again when plan is updated after being seen", () => {
mockLastSeen = TS;
mockPlan = agentPlan(TS_LATER);
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).toHaveBeenCalledWith({ quiet: true, inCenter: true });
});
it("does not open when plan panel already exists in layout", () => {
mockGetPanel.mockReturnValue({ id: "plan" });
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not open when plan is null", () => {
mockPlan = null;
renderHook(() => usePlanPanelAutoOpen());
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
});
describe("usePlanPanelAutoOpen — eager fetch", () => {
beforeEach(() => {
vi.clearAllMocks();
mockActiveTaskId = "task-1";
mockPlan = agentPlan();
mockLastSeen = undefined;
mockIsLoaded = true;
mockConnectionStatus = "connected";
mockIsRestoringLayout = false;
mockApi = { getPanel: mockGetPanel };
mockGetPanel.mockReturnValue(null);
mockGetTaskPlan.mockResolvedValue(null);
});
it("eagerly fetches the plan when not yet loaded", () => {
mockIsLoaded = false;
mockPlan = null;
renderHook(() => usePlanPanelAutoOpen());
expect(mockGetTaskPlan).toHaveBeenCalledWith("task-1");
expect(mockSetTaskPlanLoading).toHaveBeenCalledWith("task-1", true);
});
it("does not fetch when WS is disconnected", () => {
mockIsLoaded = false;
mockConnectionStatus = "connecting";
renderHook(() => usePlanPanelAutoOpen());
expect(mockGetTaskPlan).not.toHaveBeenCalled();
});
it("acknowledges the plan on hydrate when the panel was restored", () => {
mockGetPanel.mockReturnValue({ id: "plan" });
mockLastSeen = undefined;
renderHook(() => usePlanPanelAutoOpen());
expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1");
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not acknowledge a live update when lastSeen is already recorded", () => {
mockGetPanel.mockReturnValue({ id: "plan" });
mockLastSeen = TS;
mockPlan = agentPlan(TS_LATER);
renderHook(() => usePlanPanelAutoOpen());
expect(mockMarkTaskPlanSeen).not.toHaveBeenCalled();
expect(mockAddPlanPanel).not.toHaveBeenCalled();
});
it("does not retry the eager fetch after a failure", async () => {
mockIsLoaded = false;
mockPlan = null;
let rejectFn: (err: Error) => void = () => {};
mockGetTaskPlan.mockImplementation(
() =>
new Promise((_, reject) => {
rejectFn = reject;
}),
);
const { rerender } = renderHook(() => usePlanPanelAutoOpen());
expect(mockGetTaskPlan).toHaveBeenCalledTimes(1);
rejectFn(new Error("boom"));
await new Promise((r) => setTimeout(r, 0));
rerender();
rerender();
expect(mockGetTaskPlan).toHaveBeenCalledTimes(1);
});
});
describe("usePlanPanelAutoOpen — race guards", () => {
beforeEach(() => {
vi.clearAllMocks();
mockActiveTaskId = "task-1";
mockPlan = agentPlan();
mockLastSeen = undefined;
mockIsLoaded = true;
mockConnectionStatus = "connected";
mockIsRestoringLayout = false;
mockApi = { getPanel: mockGetPanel };
mockGetPanel.mockReturnValue(null);
mockGetTaskPlan.mockResolvedValue(null);
});
it("does not overwrite a newer WS-delivered plan with an older HTTP result", async () => {
mockIsLoaded = false;
mockPlan = null;
let resolveFn: (v: TaskPlan | null) => void = () => {};
mockGetTaskPlan.mockImplementation(
() =>
new Promise((resolve) => {
resolveFn = resolve;
}),
);
renderHook(() => usePlanPanelAutoOpen());
// WS delivers the latest plan while the HTTP fetch is in flight.
mockPlan = agentPlan(TS_LATER);
// HTTP resolves with an older snapshot of the same plan.
resolveFn(agentPlan(TS));
await new Promise((r) => setTimeout(r, 0));
expect(mockSetTaskPlan).not.toHaveBeenCalled();
});
it("does not overwrite a WS-delivered plan when the fetch resolves with null", async () => {
mockIsLoaded = false;
mockPlan = null;
let resolveFn: (v: TaskPlan | null) => void = () => {};
mockGetTaskPlan.mockImplementation(
() =>
new Promise((resolve) => {
resolveFn = resolve;
}),
);
renderHook(() => usePlanPanelAutoOpen());
// Simulate a WS event populating the store while the HTTP fetch is in flight.
mockPlan = agentPlan();
resolveFn(null);
await new Promise((r) => setTimeout(r, 0));
expect(mockSetTaskPlan).not.toHaveBeenCalled();
});
it("retries the eager fetch after WS reconnects following a failure", async () => {
mockIsLoaded = false;
mockPlan = null;
mockGetTaskPlan.mockRejectedValueOnce(new Error("boom"));
mockGetTaskPlan.mockResolvedValueOnce(null);
const { rerender } = renderHook(() => usePlanPanelAutoOpen());
expect(mockGetTaskPlan).toHaveBeenCalledTimes(1);
await new Promise((r) => setTimeout(r, 0));
// WS disconnect — clears the attempted set
mockConnectionStatus = "connecting";
rerender();
// WS reconnect — fetches again
mockConnectionStatus = "connected";
rerender();
expect(mockGetTaskPlan).toHaveBeenCalledTimes(2);
});
});