Files
PaperClipAI/server/src/__tests__/live-events-toast.test.ts
T
DottaandClaude Opus 5.5 2a2d3f6e23 fix(ui): stop refetching the @task mention pool on every task event (#19931)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI keeps its caches fresh from live events in
`LiveUpdatesProvider`
> - Each task activity event invalidates the whole `["issues",
companyId]` query prefix
> - That prefix also holds the `@task` mention pool: the 100 most
recently updated tasks, about 315 KB per response
> - A chat turn emits many comment, status and run events, so an open
task page downloads that list again for almost every event
> - This pull request keeps the pool fresh from push events without a
refetch per event: a closed server flag marks real membership or title
changes, progress on pooled tasks reorders the cache in place, and other
progress refetches at most once per 30 seconds
> - The benefit is less bandwidth and CPU for users with an open task or
chat, and smaller eval traces

## Linked Issues or Issue Description

**What happened**
While a task chat page is open, the browser repeats `GET
/api/companies/:id/issues?limit=100&sortField=updated&sortDir=desc`
(about 315 KB each). One 10-minute chat-eval attempt recorded about 194
of these requests. The trigger is `invalidateActivityQueries` in
`ui/src/context/LiveUpdatesProvider.tsx`. For every `entityType:
"issue"` activity event, it invalidates
`queryKeys.issues.list(companyId)`. That prefix matches
`queryKeys.issues.mentionPool(companyId)`, which `IssueDetail` (and
`NewIssueDialog`) keep active for the `@task` picker. Invalidation
refetches active queries and ignores `staleTime`.

**Expected behavior**
Comments, run progress and status changes on any task do not download
the full mention pool again. The pool still updates when a task that the
picker can show is created, deleted or renamed.

**Steps to reproduce**
1. Run `pnpm dev` and create a company with about 140 tasks.
2. Open a task page, for example `/REF/issues/REF-1`.
3. Every 4 seconds for 15 rounds, post comments on the open task, on a
task in the top 100, and on a task outside the top 100, and change the
open task's priority (60 events).
4. Count requests to `issues?limit=100&sortField=updated`. Before this
change there are 15 (about 3.3 MB). After this change there are 2 (about
480 KB).

## What Changed

- New shared helper `issueActivityChangesTaskIdentity`
(`packages/shared/src/issue-activity-identity.ts`). It is true for a
created, deleted, hidden, unhidden or renamed task.
- `server/src/realtime/live-events-ws.ts`: task `activity.logged` events
now carry the closed boolean `taskIdentityChanged`. Authenticated
sockets still get no `details`; the flag carries no task content.
- `ui/src/context/LiveUpdatesProvider.tsx` refreshes the mention pool
this way:
- The task identity flag is true (or full details show a create, delete,
hide, unhide or rename): the pool refetches at once.
- Other events on a task already in the pool: the task moves to the
front of the cached list. There is no request. The freshness clock does
not change, and a pending invalidation is never overwritten.
- Other events on a task outside the pool: one trailing refetch at most
every 30 seconds, because the task can now be in the top 100.
  - The picker is closed: the pool is only marked stale.
- `ui/src/lib/query-invalidation-batcher.ts`: new `excludingQueryKeys()`
keyed exclusion predicate. The batcher coalesces it and uses it for
prefix coverage. The broad `["issues", companyId]` invalidation uses it
to skip the pool, so a burst is again one broad refresh per batch.
- Tests: `ui/src/context/LiveUpdatesProvider.mention-pool.test.ts`
(authenticated event shape, hide and unhide, the 30-second window,
reorder safety, a burst through the batcher), new batcher tests,
`packages/shared/src/issue-activity-identity.test.ts`, and socket
assertions in `server/src/__tests__/live-events-toast.test.ts`.

## Verification

- `cd ui && npx vitest run src/context/LiveUpdatesProvider
src/lib/query-invalidation-batcher`: 231 tests pass.
- `cd server && npx vitest run src/__tests__/live-events-toast.test.ts`:
11 tests pass. In authenticated mode the socket keeps `details` off and
sends `taskIdentityChanged` (false for routine updates, true for a
create, rename or hide).
- `cd packages/shared && npx vitest run
src/issue-activity-identity.test.ts`: 14 tests pass.
- The new UI tests fail against the first commit of this PR: the
authenticated burst, hide, unhide, the 30-second window and the batcher
burst.
- `tsc --noEmit` for ui, server and shared: no new errors.
- Browser measurement: a local `pnpm dev` instance (local trusted),
headless Chromium on a task page, 141 tasks, 60 events over about 63
seconds (the reproduction steps above):
  - Before (master UI): **15** mention-pool requests, about 3.3 MB.
  - After: **2** requests, about 480 KB (one per 30-second window).
- Freshness after the fix: one rename, hide, unhide or create each
caused **1** refetch. A priority change caused **0**.
- I did not run a browser session in authenticated mode. The
authenticated event shape is covered by the socket test and the UI unit
tests.

## Risks

- A task that moves into the top 100 can be missing from the `@task`
picker for up to 30 seconds.
- The cached order is approximate between refetches: a moved task goes
to the front, but its `updatedAt` value is not changed.
- Other queries under `["issues", companyId]` (descendants,
created-from, summaries, onboarding first task) still refresh for each
event batch. They are small, but they can get the same treatment later.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code
execution in Claude Code.

## 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-07 20:55:39 -06:00

329 lines
17 KiB
TypeScript

import { createServer } from "node:http";
import { once } from "node:events";
import { getTableName, type SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { activityToastIdentity } from "../realtime/activity-toast-identity.js";
import { WebSocket } from "ws";
import { describe, expect, it, vi } from "vitest";
import { setupLiveEventsWebSocketServer } from "../realtime/live-events-ws.js";
import { publishLiveEvent } from "../services/live-events.js";
import { taskToastEligible } from "../realtime/task-toast-eligibility.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
vi.mock("../realtime/task-toast-eligibility.js", () => ({ taskToastEligible: vi.fn(async () => false) }));
vi.mock("../services/authorization.js", () => ({
authorizationService: () => ({ decide: async ({ resource }: { resource: { issueId: string } }) => ({ allowed: resource.issueId !== "hidden" }) }),
}));
vi.mock("../services/heartbeat-run-privacy.js", () => ({
getHeartbeatRunReadDecisions: async (_db: unknown, _access: unknown, _actor: unknown, runs: { issueId?: string }[]) =>
runs.map(run => run.issueId !== "hidden"),
}));
vi.mock("../services/case-access.js", () => ({
canActorReadCase: async (_db: unknown, _actor: unknown, record: { id: string }) => record.id !== "hidden-case",
}));
function database(onPredicate: (kind: "where" | "join", condition: SQL) => void = () => {}) {
return { select: (fields: Record<string, unknown>) => {
let table = "";
const query = {
from: (value: Parameters<typeof getTableName>[0]) => { table = getTableName(value); return query; },
innerJoin: (_table: unknown, condition: SQL) => { onPredicate("join", condition); return query; },
where: (condition: SQL) => { onPredicate("where", condition); return query; },
limit: async () => {
if (table === "issues") return [{ id: "task", identifier: "PAP-1234" }];
if (table === "company_memberships") return fields.name ? [{ name: "Alex Example", image: "/alex.png" }] : [{ id: "membership" }];
if (table === "agents") return [{ name: "Coder", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "bubblegum-sky" } }];
return [];
},
};
return query;
} };
}
describe("authenticated activity notifications", () => {
it("retains live delivery idle work until the identifier read settles after the last socket closes", async () => {
const baseline = idleWorkSnapshot().active;
let release!: () => void, entered!: () => void;
const pending = new Promise<void>(resolve => { release = resolve; });
const started = new Promise<void>(resolve => { entered = resolve; });
const db = database(), select = db.select;
db.select = fields => {
const query = select(fields);
if (fields.identifier) {
const limit = query.limit;
query.limit = async () => { entered(); await pending; return limit(); };
}
return query;
};
const server = createServer();
const wss = setupLiveEventsWebSocketServer(server, db as never, { deploymentMode: "local_trusted" });
const closed = new Promise<void>(resolve => wss.on("connection", socket => socket.on("close", resolve)));
server.listen(0, "127.0.0.1");
await once(server, "listening");
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
try {
await once(socket, "open");
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(baseline));
// System activity has no actor lookup: this gate is the new identifier SQL.
publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
action: "issue.updated", entityType: "issue", entityId: "visible", actorType: "system",
} });
await started;
socket.terminate();
await closed;
expect(wss.clients.size).toBe(0);
expect(idleWorkSnapshot().active).toBe(baseline + 1);
release();
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(baseline));
} finally {
release();
socket.terminate();
await new Promise<void>(resolve => server.close(() => resolve()));
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(baseline));
}
});
it.each(["local_trusted", "authenticated"] as const)("keeps authorized notifications when optional identifier reads fail (%s)", async deploymentMode => {
const db = database();
const select = db.select;
const identifierRead = vi.fn();
db.select = fields => {
if (fields.identifier) {
identifierRead();
if (identifierRead.mock.calls.length <= 3) throw new Error("Identifier lookup unavailable");
}
return select(fields);
};
const server = createServer();
setupLiveEventsWebSocketServer(server, db as never, { deploymentMode,
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
server.listen(0, "127.0.0.1");
await once(server, "listening");
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
try {
await once(socket, "open");
const messages: Array<{ payload: Record<string, unknown> }> = [];
const delivered = new Promise<void>(resolve => socket.on("message", data => {
const event = JSON.parse(data.toString());
messages.push(event);
if (event.payload.entityId === "after-failure") resolve();
}));
const activity = (entityId: string, actorType: string) => publishLiveEvent({
companyId: "company", type: "activity.logged", payload: {
action: "issue.updated", entityType: "issue", entityId, actorType,
actorId: actorType === "user" ? "viewer" : actorType === "agent" ? "agent" : null,
issueIdentifier: "SPOOF-1", details: { secret: "private task details" },
},
});
if (deploymentMode === "authenticated") activity("hidden", "system");
for (const actorType of ["system", "agent", "user"]) {
activity("visible", actorType);
}
activity("after-failure", "system");
await delivered;
expect(messages).toHaveLength(4);
expect(identifierRead).toHaveBeenCalledTimes(4);
for (const [index, actorType] of ["system", "agent", "user"].entries()) {
const event = messages[index];
expect(event.payload).toMatchObject({ entityId: "visible", actorType, issueIdentifier: null });
if (actorType === "agent") expect(event.payload.actorName).toBe("Coder");
if (actorType === "user") expect(event.payload.actorName).toBe("Alex Example");
if (deploymentMode === "authenticated") expect(event.payload).not.toHaveProperty("details");
// Routine updates are not task identity changes.
expect(event.payload.taskIdentityChanged).toBe(false);
}
expect(messages[3].payload.issueIdentifier).toBe("PAP-1234");
} finally {
socket.terminate();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
it("preserves only an authorized handoff task identity for empty case-panel refresh", async () => {
const server = createServer();
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "authenticated",
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
server.listen(0, "127.0.0.1");
await once(server, "listening");
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
try {
await once(socket, "open");
const messages: Array<{ payload: Record<string, unknown> }> = [];
socket.on("message", data => messages.push(JSON.parse(data.toString())));
const activity = (entityId: string, issueId: string, action = "case.handoff_applied") => publishLiveEvent({
companyId: "company", type: "activity.logged", payload: {
action, entityType: "case", entityId, issueId,
details: { instructions: "private handoff instructions", phase: "verification" },
},
});
activity("hidden-case", "visible");
activity("visible-case", "hidden");
const first = once(socket, "message");
activity("visible-case", "visible");
await first;
expect(messages).toHaveLength(1);
expect(messages[0].payload).toEqual({ action: "case.handoff_applied", entityType: "case", entityId: "visible-case", issueId: "visible", actorType: null, actorId: null });
const second = once(socket, "message");
activity("visible-case", "visible", "case.updated");
await second;
expect(messages[1].payload).toEqual({ action: "case.updated", entityType: "case", entityId: "visible-case", actorType: null, actorId: null });
} finally {
socket.terminate();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
it("uses the persisted local-board user identity for local task eligibility", async () => {
vi.mocked(taskToastEligible).mockImplementation(async (_db, _event, userId) => userId === "local-board");
const server = createServer();
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "local_trusted" });
server.listen(0, "127.0.0.1");
await once(server, "listening");
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
try {
await once(socket, "open");
const delivery = once(socket, "message");
publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
action: "issue.updated", entityType: "issue", entityId: "visible", actorType: "agent", actorId: "agent",
} });
const [data] = await delivery;
expect(JSON.parse(data.toString()).toastEligible).toBe(true);
expect(JSON.parse(data.toString()).payload.issueIdentifier).toBe("PAP-1234");
expect(taskToastEligible).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), "local-board");
} finally {
socket.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
vi.mocked(taskToastEligible).mockReset().mockResolvedValue(false);
}
});
it("projects eligibility per viewer and keeps authorized updates on lookup failure", async () => {
vi.mocked(taskToastEligible).mockImplementation(async (_db, event, userId) => {
if (event.payload.entityId === "lookup-failure") throw new Error("Relevance unavailable");
return userId === "alice";
});
const server = createServer();
setupLiveEventsWebSocketServer(server, database() as never, {
deploymentMode: "authenticated",
resolveCloudActor: async (req) => ({ userId: String(req.headers["x-test-viewer"]), companyIds: ["company"] }),
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const url = `ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`;
const alice = new WebSocket(url, { headers: { "x-test-viewer": "alice" } });
const bob = new WebSocket(url, { headers: { "x-test-viewer": "bob" } });
try {
await Promise.all([once(alice, "open"), once(bob, "open")]);
for (const entityId of ["visible", "lookup-failure"]) {
const deliveries = Promise.all([once(alice, "message"), once(bob, "message")]);
const original = publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
action: "issue.comment_added", entityType: "issue", entityId, actorType: "agent", actorId: "agent",
} });
const [a, b] = (await deliveries).map(([data]) => JSON.parse(data.toString()));
expect(a.toastEligible).toBe(entityId === "visible");
expect(b.toastEligible).toBe(false);
expect(a.payload.entityId).toBe(entityId);
expect(b.payload.entityId).toBe(entityId);
expect(a.payload.issueIdentifier).toBe("PAP-1234");
expect(b.payload.issueIdentifier).toBe("PAP-1234");
expect(original).not.toHaveProperty("toastEligible");
}
} finally {
alice.terminate(); bob.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
vi.mocked(taskToastEligible).mockReset().mockResolvedValue(false);
}
});
it.each(["authenticated", "local_trusted"] as const)("keeps task and actor identity for %s without exposing hidden tasks or copying private details", async (deploymentMode) => {
const server = createServer();
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode,
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
server.listen(0, "127.0.0.1");
await once(server, "listening");
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
try {
await once(socket, "open");
const messages: Array<{ payload: Record<string, unknown> }> = [];
socket.on("message", (data) => messages.push(JSON.parse(data.toString())));
const activity = (entityId: string, actorType = "user") => publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
action: "issue.created", entityType: "issue", entityId, actorType, actorId: actorType === "user" ? "viewer" : "agent",
issueIdentifier: "SPOOF-1",
details: { bodySnippet: "private comment", secret: "not for the socket" },
} });
if (deploymentMode === "authenticated") activity("hidden");
const first = once(socket, "message");
activity("visible");
await first;
expect(messages).toHaveLength(1);
expect(messages[0].payload).toMatchObject({ action: "issue.created", entityType: "issue", entityId: "visible", issueIdentifier: "PAP-1234", actorType: "user", actorId: "viewer", actorName: "Alex Example", actorImage: "/alex.png" });
if (deploymentMode === "authenticated") expect(messages[0].payload).not.toHaveProperty("details");
const second = once(socket, "message");
activity("visible", "agent");
await second;
expect(messages[1].payload).toMatchObject({ issueIdentifier: "PAP-1234", actorName: "Coder", actorType: "agent", actorId: "agent", actorAppearance: { paletteId: "bubblegum-sky" } });
if (deploymentMode === "authenticated") expect(messages[1].payload).not.toHaveProperty("details");
expect(messages[0].payload.taskIdentityChanged).toBe(true);
const third = once(socket, "message");
publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
action: "issue.updated", entityType: "issue", entityId: "visible", actorType: "user", actorId: "viewer",
details: { title: "Secret new title", hiddenAt: null },
} });
await third;
// A rename or hide is flagged with a closed boolean; its content stays off the socket.
expect(messages[2].payload.taskIdentityChanged).toBe(true);
if (deploymentMode === "authenticated") expect(JSON.stringify(messages[2].payload)).not.toContain("Secret new title");
} finally {
socket.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
describe("activity profile query boundaries", () => {
it("reads the task identifier within its company even when there is no actor", async () => {
const predicates: { sql: string; params: unknown[] }[] = [];
const db = database((_kind, condition) => {
const { sql, params } = new PgDialect().sqlToQuery(condition);
predicates.push({ sql, params });
});
expect(await activityToastIdentity(db as never, "company-A", {
action: "issue.updated", entityType: "issue", entityId: "task-A", actorType: "system",
})).toMatchObject({ issueIdentifier: "PAP-1234" });
expect(predicates).toEqual([
{ sql: '("issues"."company_id" = $1 and "issues"."id" = $2)', params: ["company-A", "task-A"] },
]);
});
it("requires the exact company, human actor, and active membership before reading a person profile", async () => {
const predicates: { kind: string; sql: string; params: unknown[] }[] = [];
const db = database((kind, condition) => {
const { sql, params } = new PgDialect().sqlToQuery(condition);
predicates.push({ kind, sql, params });
});
await activityToastIdentity(db as never, "company-A", {
action: "issue.created", actorType: "user", actorId: "person-A",
});
expect(predicates).toEqual([
{ kind: "join", sql: '"user"."id" = "company_memberships"."principal_id"', params: [] },
{ kind: "where", sql: '("company_memberships"."company_id" = $1 and "company_memberships"."principal_type" = $2 and "company_memberships"."principal_id" = $3 and "company_memberships"."status" = $4)',
params: ["company-A", "user", "person-A", "active"] },
]);
});
it("requires both the exact company and agent ID before reading an agent profile", async () => {
const predicates: { kind: string; sql: string; params: unknown[] }[] = [];
const db = database((kind, condition) => {
const { sql, params } = new PgDialect().sqlToQuery(condition);
predicates.push({ kind, sql, params });
});
await activityToastIdentity(db as never, "company-A", {
action: "issue.created", actorType: "agent", actorId: "agent-A",
});
expect(predicates).toEqual([
{ kind: "where", sql: '("agents"."company_id" = $1 and "agents"."id" = $2)',
params: ["company-A", "agent-A"] },
]);
});
});