Files
PaperClipAI/ui/src/api/issue-summaries.test.ts
T
DottaandClaude Opus 5.5 7e93ccb4b3 perf: fetch recent task summaries in one request (#20389)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI sidebar shows the person's recent tasks on every page,
including the Mine inbox
> - The sidebar refreshes those rows through `GET
/companies/:id/issues/summaries`, which accepted at most five IDs
> - `useRecentTasks` sent the chunks one after another, so a 25-task
history cost five sequential requests
> - On staging each request took 3–6 s, so the recent-task rows settled
about 20 s after load and every request competed with the inbox rows
> - This pull request raises the per-request cap to 50 and fetches any
remaining chunks in parallel
> - The benefit is one summaries request for a typical history instead
of a serial chain

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The refresh of the sidebar's recent tasks on page load.

**Subsystem affected**
Board UI (`ui/src/hooks/useRecentTasks.ts`) and the `/issues/summaries`
route bound (`packages/shared`, `server/src/routes/issues.ts`).

**Current behavior**
A history of N recent tasks sends ceil(N/5) summaries requests in
series. Measured on staging on 2026-11-09 for a cold Mine load: five
requests of five IDs, started at 1.0 s, 4.3 s, 8.1 s, 12.3 s and 18.4 s,
each taking 3.3–6.1 s.

**Proposed behavior**
Up to 50 recent tasks are one request. Longer histories send their
chunks at the same time.

**Reason and benefit**
The staging server is saturated, so each extra serial round trip delays
the sidebar and the inbox rows. One request removes four round trips
from a typical load.

## What Changed

- `ISSUE_SUMMARIES_MAX_IDS` is now 50 (was 5). The shared validator, the
route's 400 message, the service guard and the OpenAPI description use
the new bound.
- `useRecentTasks` builds all chunks first and fetches them at most
three at a time (`RECENT_TASK_SUMMARY_CONCURRENCY`). The recent list has
no size cap, so this keeps a long history from becoming a burst. The
cache key, omission handling and pruning are unchanged. A failed chunk
still fails the batch, so nothing is pruned on a partial failure.
- Tests: the route test accepts exactly `MAX` IDs and rejects `MAX + 1`;
a hook test checks that at most three chunks are in flight; the API test
uses the constant; `useRecentTasks` tests cover a 25-task history in one
request and a 103-task history with three chunks in flight together,
with and without a failed chunk.
- New browser check
`tests/perf/collections/recent-task-summaries.spec.ts`: a Mine load with
a 12-task history sends summaries requests that each carry all 12 IDs,
and the server answers 200 with all 12 tasks.

## Verification

- `pnpm verify` (selected Vitest files) passes.
- `server`: `npx vitest run src/__tests__/issue-summaries-routes.test.ts
src/__tests__/issue-display-status.test.ts` passes (embedded
PostgreSQL).
- `ui`: `npx vitest run src/hooks/useRecentTasks.test.tsx
src/api/issue-summaries.test.ts
src/components/task-chat/TaskChatBlockerLinks.test.tsx
src/components/CoordinatorSidebar.test.tsx
src/components/SidebarRecentTasks.test.tsx` passes.
- Browser check against a disposable local instance:
`PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec
playwright test --config tests/perf/collections/playwright.config.ts
recent-task-summaries.spec.ts`. It passes on this branch. With
`master`'s hook and constant it fails: three requests of 5, 5 and 2 IDs.

## Risks

- Low risk. The route still validates UUIDs, deduplicates, and applies
the same read authorization per row. Blocker attention is computed with
set-based queries, so 50 IDs is the same number of queries as 5.
- During a rolling deploy, a new UI against an old server would get a
400 for a history above five tasks. The server serves the UI bundle, so
both change together on a normal deploy. The sidebar keeps its stored
snapshot when the refresh fails.

## 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:32 -06:00

53 lines
2.6 KiB
TypeScript

import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
import { issuesApi } from "./issues";
const first = "11111111-1111-4111-8111-111111111111";
const second = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
describe("issue summary HTTP requests", () => {
const fetchMock = vi.fn();
beforeEach(() => { fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); });
afterEach(() => vi.unstubAllGlobals());
it("normalizes IDs, forwards cancellation, and disables the HTTP cache", async () => {
fetchMock.mockResolvedValue(Response.json([]));
const controller = new AbortController();
await issuesApi.summaries("company", [second.toUpperCase(), ` ${first} `, first], { signal: controller.signal });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe(`/api/companies/company/issues/summaries?ids=${first}%2C${second}`);
expect(options.cache).toBe("no-store");
expect(options.signal).toBeInstanceOf(AbortSignal);
});
it("does not send empty or oversized batches", async () => {
expect(await issuesApi.summaries("company", [])).toEqual([]);
await expect(issuesApi.summaries("company", Array(ISSUE_SUMMARIES_MAX_IDS + 1).fill(first))).rejects.toThrow("Too many");
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not reuse an in-flight response after an account change", async () => {
let resolveOld!: (value: Response) => void;
fetchMock.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; }))
.mockResolvedValueOnce(Response.json([{ id: first, title: "New account" }]));
const oldAccount = issuesApi.summaries("company", [first]);
const newAccount = issuesApi.summaries("company", [first]);
expect((await newAccount)[0].title).toBe("New account");
resolveOld(Response.json([{ id: first, title: "Old account" }]));
expect((await oldAccount)[0].title).toBe("Old account");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("aborts a summary request when its observer leaves", async () => {
fetchMock.mockImplementation((_url, options) => new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
}));
const controller = new AbortController();
const pending = issuesApi.summaries("company", [first], { signal: controller.signal });
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true);
});
});