mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A company can have several human users and shared agents. > - Live task events currently produce toasts for any user who can read the task. > - This makes unrelated work interrupt other users. > - This pull request checks each user's task participation before it permits a toast. > - Users receive relevant task notifications while company views continue to refresh. ## Linked Issues or Issue Description **What happened?** Company task activity and agent failures produce noisy toasts for users who have no involvement in the task. **Expected behavior** A task qualifies when the viewer created it, commented on it, is its assigned user, deliberately acted on it under the Mine rules, or has an explicit user mention in its description or a non-deleted comment. Viewing, agent ownership, responsible-user attribution, and parent participation alone do not qualify. **Steps to reproduce** 1. Sign in as two users in the same company. 2. Have the second user create and update a task that does not involve the first user. 3. Observe a task toast in the first user's company view before this fix. The duplicate PR search found no existing change for personal task-toast eligibility. ## What Changed - Extract the existing Mine participation predicate without changing Mine behavior. - Compute task eligibility from stored tasks, comments, and run associations after WebSocket access checks. Use an exact indexed comment-mention lookup with `LIMIT 1`. - Add optional `toastEligible` metadata to each viewer's event copy. Keep it off the shared event bus. - Require eligibility for live task activity and task-linked run errors. Suppress generic agent errors. - Continue cache updates for quiet events. Refetch visible comments when authenticated events omit hydration details. Ignore stale socket callbacks after account or company changes. - Document toast semantics and add feature-map and agent-access-map coverage. - Add persisted-data, socket, UI, and authenticated browser regression coverage. ## Verification - Final commit: `1dc4f9402c5883744738cfe236ad00fa0bb5ca4c`. - All 55 current-head checks passed. Two Storybook checks were skipped because this change does not affect that coverage. - Greptile approved this exact commit with 5/5. The security scan passed. Both review threads are resolved. - Local `pnpm build`, `pnpm -r typecheck`, `pnpm check:token-gates`, and `node scripts/check-maps.mjs` passed. - The focused socket/UI suite passed 123 tests. The persisted eligibility suite passed 9 tests, including long history, exact viewer matching, and regex escaping. The existing Mine service suite previously passed 140 tests. - The named browser case `task toasts follow personal involvement in multiplayer` passed against the final commit in an isolated authenticated instance. It used two real sessions, real WebSocket delivery, and persisted tasks, edits, comments, and mentions. It verified quiet unrelated activity, viewing alone, first and historical mentions, own-action suppression, visible-task suppression, and live cache refresh. - A prior-head server shard passed its single rerun after a fixture-cleanup deadlock. The final-head CI run passed without a rerun. - The broad local run remained in progress when CI completed the full test suite. Local focused checks and CI provide the completed test evidence. ## Risks - This intentionally reduces live notifications. A new client stays quiet against an older server that omits eligibility. - Eligibility queries add work to live event delivery. A lookup failure suppresses the toast and still delivers the authorized event. - Deleted mentions cease to qualify unless another participation rule applies. Historical comment authorship retains the existing Mine behavior. - No database migration or notification-settings change is required. ## Model Used OpenAI Codex with GPT-6 assisted with implementation, tests, documentation, and PR preparation. It used repository tools, shell commands, and browser automation. The runtime did not expose an exact backend revision or context-window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-Authored-By: Paperclip <noreply@paperclip.ing>
639 lines
25 KiB
TypeScript
639 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";
|
|
|
|
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" }, { 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",
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
});
|