Files
PaperClipAI/ui/src/context/LiveUpdatesProvider.hook.test.tsx
T
DottaandClaude Opus 5.5 8004ad069e perf(ui): skip socket reconcile for reads that have no live events (#20398)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI keeps company data fresh through `LiveUpdatesProvider`,
which listens on a WebSocket and invalidates queries for each event
> - The provider closes the socket while the tab is hidden, and events
are not replayed, so it invalidates every active query when the socket
opens and when the tab returns
> - Some of those queries have no live events at all: instance settings,
plugin UI contributions, and the person's workspace and sidebar
preferences
> - Reconciling them adds requests to every tab return and to the first
socket open, on a server that is already saturated
> - This pull request excludes those reads from the reconcile and gives
them a 5-minute stale time
> - The benefit is four fewer requests on each tab return and on the
first socket open, with no change to company data freshness

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The burst of requests when a board UI tab regains focus, and the second
wave right after page load.

**Subsystem affected**
Board UI live updates (`ui/src/context/LiveUpdatesProvider.tsx`) and
query defaults (`ui/src/main.tsx`, new `ui/src/lib/live-reconcile.ts`).

**Current behavior**
On a local instance, returning to a hidden Mine tab sends 27 API
requests. Those include `instance/settings/experimental`,
`workspace-preferences/me`, `plugins/ui-contributions` and
`sidebar-preferences/me`. Live events never touch these. The same four
re-fire when the socket first opens after load.

**Proposed behavior**
The socket reconcile skips those four reads. Their stale time is 5
minutes, so a window-focus refetch runs at most every 5 minutes. Their
own mutations still invalidate them at once.

**Reason and benefit**
Each tab return costs four fewer requests. On staging, about 25 requests
re-fire together on focus, and every one delays the visible rows.

## What Changed

- New `ui/src/lib/live-reconcile.ts`: `LIVE_EVENT_FREE_QUERY_KEYS`
(instance settings, general and experimental settings, plugin UI
contributions, workspace preferences, sidebar preferences),
`isLiveReconciledQuery`, and `applyLiveEventFreeQueryDefaults`.
- `LiveUpdatesProvider` passes `isLiveReconciledQuery` as the predicate
on its invalidate-all paths: tab return, disconnected polling, socket
open, `live.resync`, and the transcript recovery that runs after a
resync. The first healthy open still also skips session and health.
- `main.tsx` applies the 5-minute stale time to those keys with
`setQueryDefaults`. No observer sets its own stale time for them.
- Not changed, and why:
- `auth/get-session` and `health` keep focus refetch and reconnect
handling for sign-out and reconnect detection.
- `cli-auth/me` (board access) keeps the reconcile because it detects
revoked access.
- The company list keeps the reconcile because live events invalidate
it.
- Company data (agents, chats, projects, approvals, labels, join
requests, live runs and others) keeps the reconcile. The socket is
closed while hidden, so the reconcile is how missed events arrive.
Keeping the socket open while hidden is a larger change and is not in
this pull request.
- Tests: new `live-reconcile.test.ts` covers the scope and the
stale-time defaults. The hidden-tab test in
`LiveUpdatesProvider.hook.test.tsx` expects the predicate.

## Verification

- `ui`: `npx vitest run src/lib/live-reconcile.test.ts
src/context/LiveUpdatesProvider src/lib/query-client.test.ts
src/lib/persisted-query-cache.test.ts` passes (283 tests).
- `pnpm verify` asks for broader CI coverage because `main.tsx` is
outside the resolved test graphs. Required CI provides it.
- Manual, on a local instance with the Mine page: hide the tab for 45 s,
then show it, and count `/api/` resource entries. `master` sends 27
requests and this branch sends 23. The four reads above are absent;
every company read is still present.

## Risks

- The reconcile was also how other tabs picked up changes to these
reads. A change made in another tab or browser now reaches this tab on
its next focus after 5 minutes, or on reload; before, it was the next
focus after 30 s. This covers an administrator changing an instance or
experimental setting or installing a plugin, and the same person
changing their workspace or sidebar preferences in another tab (for
example the coordinator or classic shell, or sidebar order). Changes
made in the same tab still apply at once.
- Low risk otherwise. Only the reconcile predicate and the stale time
change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, browser). 1M-token context window. Extended
thinking was off.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:42:54 -06:00

640 lines
25 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { act as reactAct } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { isLiveReconciledQuery } from "../lib/live-reconcile";
const { pushToast, companySelection } = vi.hoisted(() => ({
pushToast: vi.fn(() => "toast-id"),
companySelection: { id: "company-1" },
}));
vi.mock("./CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: companySelection.id, selectedCompany: { id: companySelection.id } }),
}));
vi.mock("./ToastContext", () => ({ useToastActions: () => ({ pushToast }) }));
vi.mock("../lib/router", () => ({ useLocation: () => ({ pathname: "/PAP/issues/PAP-204" }) }));
vi.mock("../api/auth", () => ({
authApi: { getSession: async () => ({ user: { id: "viewer" }, session: { id: "session", userId: "viewer" } }) },
}));
import {
__liveUpdatesTestUtils,
LiveUpdatesProvider,
useCompanyLiveEvent,
type CompanyLiveEventHandler,
} from "./LiveUpdatesProvider";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const { LiveEventSubscriptionContext, dispatchLiveEventToSubscribers } = __liveUpdatesTestUtils;
function act(callback: () => void) {
flushSync(callback);
}
function progressEvent(overrides: Partial<LiveEvent> = {}): LiveEvent {
return {
id: 1,
companyId: "company-1",
type: "heartbeat.run.progress",
createdAt: "2026-07-15T00:00:00.000Z",
payload: { issueId: "issue-1", message: "reviewing 14 open issues" },
...overrides,
};
}
describe("useCompanyLiveEvent", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
});
function renderWithSubscription(handler: CompanyLiveEventHandler) {
const subscribers = new Set<CompanyLiveEventHandler>();
const subscription = {
subscribe: (fn: CompanyLiveEventHandler) => {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
},
};
function Consumer() {
useCompanyLiveEvent(handler);
return null;
}
root = createRoot(container);
act(() => {
root!.render(
<LiveEventSubscriptionContext.Provider value={subscription}>
<Consumer />
</LiveEventSubscriptionContext.Provider>,
);
});
return subscribers;
}
it("receives events dispatched through the shared registry", () => {
const received: LiveEvent[] = [];
const subscribers = renderWithSubscription((event) => received.push(event));
act(() => dispatchLiveEventToSubscribers(subscribers, "company-1", progressEvent()));
expect(received).toHaveLength(1);
expect(received[0].payload.message).toBe("reviewing 14 open issues");
});
it("stops receiving events after unmount", () => {
const received: LiveEvent[] = [];
const subscribers = renderWithSubscription((event) => received.push(event));
act(() => root?.unmount());
root = null;
act(() => dispatchLiveEventToSubscribers(subscribers, "company-1", progressEvent()));
expect(received).toHaveLength(0);
});
it("no-ops without a surrounding provider", () => {
function Consumer() {
useCompanyLiveEvent(() => {
throw new Error("should never be called");
});
return null;
}
root = createRoot(container);
expect(() =>
act(() => {
root!.render(<Consumer />);
}),
).not.toThrow();
});
});
describe("LiveUpdatesProvider socket run notification scope", () => {
let queryClient: QueryClient;
let container: HTMLDivElement;
let root: Root | null;
let sockets: Array<{ onmessage: ((event: MessageEvent) => void) | null }>;
beforeEach(() => {
pushToast.mockClear();
companySelection.id = "company-1";
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } });
queryClient.setQueryData(queryKeys.auth.session, { user: { id: "viewer" }, session: { id: "session", userId: "viewer" } });
queryClient.setQueryData(queryKeys.issues.detail("PAP-204"), {
id: "root", companyId: "company-1", identifier: "PAP-204", assigneeAgentId: "parent-agent",
});
queryClient.setQueryData(queryKeys.issues.listByDescendantRoot("company-1", "root"), [
{ id: "child", companyId: "company-1", identifier: "PAP-205", assigneeAgentId: "child-agent", executionRunId: "child-run" },
]);
queryClient.setQueryData(queryKeys.liveRuns("company-1"), [
{ id: "child-run", issueId: "child", agentId: "child-agent", status: "running" },
]);
vi.spyOn(document, "hasFocus").mockReturnValue(true);
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
sockets = [];
vi.stubGlobal("WebSocket", class {
readyState = 1;
onmessage: ((event: MessageEvent) => void) | null = null;
onopen = null;
onclose = null;
onerror = null;
constructor() { sockets.push(this); }
close() { this.readyState = 3; }
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await reactAct(async () => root?.unmount());
root = null;
queryClient.clear();
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
async function receiveStatus(payload: Record<string, unknown>, createdAt = "2026-09-09T18:00:00.000Z", toastEligible: boolean | undefined = true) {
await reactAct(async () => {
root!.render(<QueryClientProvider client={queryClient}><LiveUpdatesProvider><span>Visible task</span></LiveUpdatesProvider></QueryClientProvider>);
});
await vi.waitFor(() => expect(sockets).toHaveLength(1));
expect(sockets[0]?.onmessage).toBeTypeOf("function");
await reactAct(async () => sockets[0]!.onmessage!(new MessageEvent("message", {
data: JSON.stringify({ id: Date.now(), companyId: "company-1", type: "heartbeat.run.status", createdAt, payload, toastEligible }),
})));
}
it.each([false, undefined])("keeps ineligible runs quiet (%s) while updating their cache", async (eligibility) => {
queryClient.setQueryData(queryKeys.runDetail("other-run"), { id: "other-run", status: "running" });
// Pass an event directly to exercise a missing property, not the helper default.
await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" });
await reactAct(async () => sockets[0]!.onmessage!(new MessageEvent("message", {
data: JSON.stringify({ id: 99, companyId: "company-1", type: "heartbeat.run.status",
createdAt: "2026-09-09T18:00:00.000Z", toastEligible: eligibility,
payload: { runId: "other-run", agentId: "other-agent", status: "failed" } }),
})));
expect(pushToast).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.runDetail("other-run"))).toMatchObject({ status: "failed" });
});
it.each([true, false, undefined])("gates task activity (%s)", async (eligibility) => {
await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" });
await reactAct(async () => sockets[0]!.onmessage!(new MessageEvent("message", {
data: JSON.stringify({ id: 99, companyId: "company-1", type: "activity.logged",
createdAt: "2026-09-09T18:00:00.000Z", toastEligible: eligibility,
payload: { entityType: "issue", entityId: "other-task", action: "issue.comment_added", actorType: "user", actorId: "another-user" } }),
})));
expect(pushToast).toHaveBeenCalledTimes(eligibility === true ? 1 : 0);
});
it("keeps generic agent errors quiet even with eligibility metadata", async () => {
await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" });
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
await reactAct(async () => sockets[0]!.onmessage!(new MessageEvent("message", {
data: JSON.stringify({ id: 99, companyId: "company-1", type: "agent.status", toastEligible: true,
createdAt: "2026-09-09T18:00:00.000Z", payload: { agentId: "other-agent", status: "error" } }),
})));
expect(pushToast).not.toHaveBeenCalled();
await vi.waitFor(() => expect(invalidate).toHaveBeenCalledWith(expect.objectContaining({
queryKey: queryKeys.agents.detail("other-agent"),
})));
});
it("ignores a callback retained from a closed socket", async () => {
await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" });
const staleCallback = sockets[0]!.onmessage!;
await reactAct(async () => root!.unmount());
root = null;
staleCallback(new MessageEvent("message", { data: JSON.stringify({
id: 99, companyId: "company-1", type: "heartbeat.run.status", toastEligible: true,
createdAt: "2026-09-09T18:00:00.000Z", payload: { runId: "stale-run", agentId: "other-agent", status: "failed" },
}) }));
expect(pushToast).not.toHaveBeenCalled();
});
it.each(["account", "company"])("ignores old socket messages after switching %s", async (scope) => {
await receiveStatus({ runId: "setup", agentId: "other-agent", status: "running" });
const staleCallback = sockets[0]!.onmessage!;
await reactAct(async () => {
if (scope === "account") {
queryClient.setQueryData(queryKeys.auth.session, {
user: { id: "new-viewer" }, session: { id: "new-session", userId: "new-viewer" },
});
} else {
companySelection.id = "company-2";
}
root!.render(<QueryClientProvider client={queryClient}><LiveUpdatesProvider><span>New scope</span></LiveUpdatesProvider></QueryClientProvider>);
});
await vi.waitFor(() => expect(sockets).toHaveLength(2));
await reactAct(async () => staleCallback(new MessageEvent("message", { data: JSON.stringify({
id: 99, companyId: "company-1", type: "heartbeat.run.status", toastEligible: true,
createdAt: "2026-09-09T18:00:00.000Z", payload: { runId: "stale-run", agentId: "other-agent", status: "failed" },
}) })));
expect(pushToast).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.runDetail("stale-run"))).toBeUndefined();
});
it("announces a run outcome once across minute-apart redeliveries but still refreshes caches", async () => {
const clock = vi.spyOn(Date, "now");
const payload = { runId: "unrelated-run", agentId: "other-agent", status: "failed", finishedAt: "2026-09-09T18:00:00.000Z" };
clock.mockReturnValue(Date.parse(payload.finishedAt));
await receiveStatus({ ...payload, deliveryId: "first" });
expect(pushToast).toHaveBeenCalledTimes(1);
queryClient.setQueryData(queryKeys.runDetail(payload.runId), { id: payload.runId, status: "running" });
for (let minute = 1; minute <= 3; minute += 1) {
const now = Date.parse(payload.finishedAt) + minute * 60_000;
clock.mockReturnValue(now);
await receiveStatus({ ...payload, deliveryId: `retry-${minute}` }, new Date(now).toISOString());
}
expect(pushToast).toHaveBeenCalledTimes(1);
expect(queryClient.getQueryData(queryKeys.runDetail(payload.runId))).toMatchObject({ status: "failed" });
await receiveStatus({ ...payload, runId: "new-failure" });
expect(pushToast).toHaveBeenCalledTimes(2);
});
it("refreshes an old terminal run without announcing its first delivery as a new failure", async () => {
queryClient.setQueryData(queryKeys.runDetail("old-run"), { id: "old-run", status: "running" });
await receiveStatus({
runId: "old-run", agentId: "other-agent", status: "failed",
finishedAt: "2026-09-07T18:00:00.000Z", deliveryId: "new-delivery-of-old-failure",
});
expect(pushToast).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.runDetail("old-run"))).toMatchObject({ status: "failed" });
});
it("does not announce an outcome again after hiding and reopening the window", async () => {
const payload = { runId: "unrelated-run", agentId: "other-agent", status: "failed" };
await receiveStatus(payload);
const visibility = vi.spyOn(document, "visibilityState", "get");
await reactAct(async () => {
visibility.mockReturnValue("hidden");
document.dispatchEvent(new Event("visibilitychange"));
});
await reactAct(async () => {
visibility.mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
});
await vi.waitFor(() => expect(sockets).toHaveLength(2));
await reactAct(async () => sockets[1]!.onmessage!(new MessageEvent("message", {
data: JSON.stringify({ id: 2, companyId: "company-1", toastEligible: true, type: "heartbeat.run.status", createdAt: "2026-09-09T18:01:00.000Z", payload }),
})));
expect(pushToast).toHaveBeenCalledTimes(1);
});
it("disconnects while hidden and reconciles active queries once on return", async () => {
await receiveStatus({ runId: "child-run", agentId: "child-agent", status: "running" });
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
const visibility = vi.spyOn(document, "visibilityState", "get");
await reactAct(async () => {
visibility.mockReturnValue("hidden");
document.dispatchEvent(new Event("visibilitychange"));
});
expect(sockets[0].onmessage).toBeNull();
invalidate.mockClear();
await reactAct(async () => {
visibility.mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
});
await vi.waitFor(() => expect(sockets).toHaveLength(2));
expect(invalidate).toHaveBeenCalledExactlyOnceWith({ type: "active", predicate: isLiveReconciledQuery }, { cancelRefetch: false });
});
it.each(["parent-agent", "child-agent"])("shows an unrelated retryable failure without issueId for %s", async (agentId) => {
// Match the retryable broadcast from execution-status-delivery.ts: it has
// exact run identity but deliberately omits issueId and provider output.
await receiveStatus({ runId: "unrelated-run", agentId, status: "failed", startedAt: "2026-09-09T17:59:00.000Z", finishedAt: "2026-09-09T18:00:00.000Z", deliveryId: "status-delivery" });
expect(pushToast).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
title: "Agent run failed", dedupeKey: "run-status:unrelated-run:failed",
action: { label: "View run", href: `/agents/${agentId}/runs/unrelated-run` },
}));
});
it("retains the existing global no-success-toast policy independently of subtree membership", async () => {
await receiveStatus({ runId: "unrelated-run", agentId: "child-agent", status: "succeeded" });
expect(pushToast).not.toHaveBeenCalled();
});
it("suppresses an exact descendant run before terminal cache removal", async () => {
await receiveStatus({ runId: "child-run", agentId: "child-agent", status: "cancelled" });
expect(pushToast).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.liveRuns("company-1"))).toEqual([]);
});
it("keeps a stopped visible task quiet when its retryable status follows live-cache eviction", async () => {
// The route uses PAP-204, while IssueDetail's conversation loads run history
// with the resolved UUID. The periodic status outbox omits issueId.
queryClient.setQueryData(queryKeys.issues.runs("root"), [
{ runId: "parent-run", agentId: "parent-agent", status: "cancelled" },
]);
queryClient.setQueryData(queryKeys.liveRuns("company-1"), [
{
id: "parent-run",
issueId: "root",
agentId: "parent-agent",
status: "running",
},
]);
await receiveStatus({
issueId: "root",
runId: "parent-run",
agentId: "parent-agent",
status: "cancelled",
triggerDetail: "system",
});
expect(pushToast).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.liveRuns("company-1"))).toEqual(
[],
);
expect(
queryClient.getQueryData(queryKeys.issues.runs("PAP-204")),
).toBeUndefined();
await receiveStatus({
runId: "parent-run",
agentId: "parent-agent",
status: "cancelled",
startedAt: "2026-09-09T17:59:00.000Z",
finishedAt: "2026-09-09T18:00:00.000Z",
deliveryId: "retryable-parent-status",
});
expect(pushToast).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.issues.runs("root"))).toEqual([
{ runId: "parent-run", agentId: "parent-agent", status: "cancelled" },
]);
});
it.each(["active", "live", "linked"] as const)(
"uses only an exact run from the visible task's canonical %s cache",
async (cache) => {
if (cache === "active") {
queryClient.setQueryData(queryKeys.issues.activeRun("root"), {
id: "parent-run",
});
} else if (cache === "live") {
queryClient.setQueryData(queryKeys.issues.liveRuns("root"), [
{ id: "parent-run" },
]);
} else {
queryClient.setQueryData(queryKeys.issues.runs("root"), [
{ runId: "parent-run" },
]);
}
await receiveStatus({
runId: "parent-run",
agentId: "parent-agent",
status: "cancelled",
deliveryId: "retryable-parent-status",
});
expect(pushToast).not.toHaveBeenCalled();
await receiveStatus({
runId: "unrelated-run",
agentId: "parent-agent",
status: "cancelled",
deliveryId: "retryable-unrelated-status",
});
expect(pushToast).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
dedupeKey: "run-status:unrelated-run:cancelled",
}),
);
},
);
it.each(["background", "explicit unrelated issue"])(
"retains a canonical-history run notification for an %s event",
async (scope) => {
queryClient.setQueryData(queryKeys.issues.runs("root"), [
{ runId: "parent-run" },
]);
if (scope === "background")
vi.mocked(document.hasFocus).mockReturnValue(false);
await receiveStatus({
runId: "parent-run",
agentId: "parent-agent",
status: "cancelled",
deliveryId: "retryable-parent-status",
...(scope === "explicit unrelated issue"
? { issueId: "unrelated" }
: {}),
});
expect(pushToast).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
dedupeKey: "run-status:parent-run:cancelled",
}),
);
},
);
it("does not suppress an explicit unrelated task even when its run is cached", async () => {
await receiveStatus({ issueId: "unrelated", runId: "child-run", agentId: "child-agent", status: "failed" });
expect(pushToast).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ dedupeKey: "run-status:child-run:failed" }));
});
it("keeps a stopped descendant quiet after its execution lock and live entry clear", async () => {
// A previously visited child has canonical run history. The refreshed
// subtree no longer holds executionRunId after the rich terminal event.
queryClient.setQueryData(queryKeys.issues.runs("child"), [
{ runId: "child-run", agentId: "child-agent", status: "cancelled" },
]);
await receiveStatus({
issueId: "child",
runId: "child-run",
agentId: "child-agent",
status: "cancelled",
triggerDetail: "system",
});
expect(pushToast).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.liveRuns("company-1"))).toEqual(
[],
);
queryClient.setQueryData(
queryKeys.issues.listByDescendantRoot("company-1", "root"),
[
{
id: "child",
companyId: "company-1",
identifier: "PAP-205",
assigneeAgentId: "child-agent",
executionRunId: null,
},
],
);
await receiveStatus({
runId: "child-run",
agentId: "child-agent",
status: "cancelled",
startedAt: "2026-09-09T17:59:00.000Z",
finishedAt: "2026-09-09T18:00:00.000Z",
deliveryId: "retryable-child-status",
});
expect(pushToast).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.issues.runs("child"))).toEqual([
{ runId: "child-run", agentId: "child-agent", status: "cancelled" },
]);
});
it.each([
["child", "active"],
["child", "live"],
["child", "linked"],
["PAP-205", "active"],
["PAP-205", "live"],
["PAP-205", "linked"],
] as const)(
"uses the current descendant's exact %s %s cache",
async (ref, cache) => {
queryClient.setQueryData(queryKeys.liveRuns("company-1"), []);
queryClient.setQueryData(
queryKeys.issues.listByDescendantRoot("company-1", "root"),
[
{
id: "child",
companyId: "company-1",
identifier: "PAP-205",
assigneeAgentId: "child-agent",
executionRunId: null,
},
],
);
if (cache === "active") {
queryClient.setQueryData(queryKeys.issues.activeRun(ref), {
id: "child-run",
});
} else if (cache === "live") {
queryClient.setQueryData(queryKeys.issues.liveRuns(ref), [
{ id: "child-run" },
]);
} else {
queryClient.setQueryData(queryKeys.issues.runs(ref), [
{ runId: "child-run" },
]);
}
await receiveStatus({
runId: "child-run",
agentId: "child-agent",
status: "cancelled",
deliveryId: "retryable-child-status",
});
expect(pushToast).not.toHaveBeenCalled();
},
);
it.each([
"background",
"explicit unrelated issue",
"unrelated same-agent run",
"removed descendant",
])("retains child-history notifications for %s", async (scope) => {
queryClient.setQueryData(queryKeys.liveRuns("company-1"), []);
queryClient.setQueryData(
queryKeys.issues.listByDescendantRoot("company-1", "root"),
scope === "removed descendant"
? []
: [
{
id: "child",
companyId: "company-1",
identifier: "PAP-205",
assigneeAgentId: "child-agent",
executionRunId: null,
},
],
);
queryClient.setQueryData(queryKeys.issues.runs("child"), [
{ runId: "child-run" },
]);
queryClient.setQueryData(queryKeys.issues.runs("unrelated"), [
{ runId: "unrelated-run" },
]);
if (scope === "background")
vi.mocked(document.hasFocus).mockReturnValue(false);
const runId =
scope === "unrelated same-agent run" ? "unrelated-run" : "child-run";
await receiveStatus({
runId,
agentId: "child-agent",
status: "failed",
deliveryId: "retryable-child-status",
...(scope === "explicit unrelated issue" ? { issueId: "unrelated" } : {}),
});
expect(pushToast).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ dedupeKey: `run-status:${runId}:failed` }),
);
});
it("cannot create a run toast from an agent-only status with no run receipt", async () => {
await receiveStatus({ agentId: "child-agent", status: "failed" });
expect(pushToast).not.toHaveBeenCalled();
});
it.each(["child", "unrelated"])(
"routes a never-visited child's retryable status by its explicit %s task",
async (issueId) => {
queryClient.setQueryData(queryKeys.liveRuns("company-1"), []);
queryClient.setQueryData(
queryKeys.issues.listByDescendantRoot("company-1", "root"),
[
{
id: "child",
companyId: "company-1",
identifier: "PAP-205",
assigneeAgentId: "child-agent",
executionRunId: null,
},
],
);
for (const ref of ["child", "PAP-205"]) {
expect(
queryClient.getQueryData(queryKeys.issues.runs(ref)),
).toBeUndefined();
expect(
queryClient.getQueryData(queryKeys.issues.activeRun(ref)),
).toBeUndefined();
expect(
queryClient.getQueryData(queryKeys.issues.liveRuns(ref)),
).toBeUndefined();
}
await receiveStatus({
issueId,
runId: "child-run",
agentId: "child-agent",
status: "cancelled",
startedAt: "2026-09-09T17:59:00.000Z",
finishedAt: "2026-09-09T18:00:00.000Z",
deliveryId: "retryable-uncached-child-status",
});
if (issueId === "child") expect(pushToast).not.toHaveBeenCalled();
else
expect(pushToast).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
dedupeKey: "run-status:child-run:cancelled",
}),
);
},
);
});