From 2e23f909885c7d56e9eb38d303af347194572458 Mon Sep 17 00:00:00 2001 From: Anton Arapov Date: Fri, 29 May 2026 09:53:07 +0200 Subject: [PATCH] fix(entries): preserve merged timeline pagination --- CHANGELOG.md | 20 +++-- HOWTO.md | 2 +- NOTES-ON-CAPSULE-API.md | 14 ++-- ...{wire-trace-v166.ts => wire-trace-v170.ts} | 17 ++-- src/tools/entries.ts | 82 ++++++++++++++----- tests/entries.test.ts | 41 ++++++++-- 6 files changed, 129 insertions(+), 47 deletions(-) rename scripts/{wire-trace-v166.ts => wire-trace-v170.ts} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7dfd9..c607a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,17 +64,25 @@ versions adhere to [Semantic Versioning](https://semver.org). unit tests + the SDK round-trip test in `tests/tool-annotations.test.ts` lock the new contract. +- **`list_party_entries.includeLinkedPersons` merged pagination now + preserves upstream `nextPage`.** The merged feed fetches enough + entries from each party to cover the requested window (up to + Capsule's per-party cap of 100) and carries forward Capsule's + `Link rel=next` signal. This avoids a false "no next page" result + when a linked person's first page is exactly full but older entries + still exist. + ### Added - **`list_party_entries.includeLinkedPersons` (optional, default `false`).** Opt-in flag that surfaces entries filed against an organisation's linked people in addition to the org's own entries. Closes a long-standing workflow gap: Capsule's API files - each entry against exactly one party row (verified v1.6.6 + each entry against exactly one party row (verified v1.7.0 wire-trace probe 4 — `POST /entries` rejects multi-party bodies with 422 "entry must be linked to either a party, opportunity or kase"), so customer-facing emails typically land on a person row - and the org's `/entries` response misses them. Pre-v1.6.6, the + and the org's `/entries` response misses them. Pre-v1.7.0, the fix required a manual `get_party` → `list_party_entries(org)` → `list_employees(org)` → `list_party_entries(personN)` chain. With the flag, the connector enumerates linked persons via @@ -86,7 +94,7 @@ versions adhere to [Semantic Versioning](https://semver.org). Behaviour when `includeLinkedPersons: true` is passed against a PERSON party: silent no-op — persons have no linked-people - relationship in the data model (verified v1.6.6 probe 5, + relationship in the data model (verified v1.7.0 probe 5, `/parties/{personId}/people` returns 200 with an empty array). The flag is safe to default-on in callers without conditional branching. @@ -100,14 +108,14 @@ versions adhere to [Semantic Versioning](https://semver.org). ### Documentation - **NOTES-ON-CAPSULE-API.md §32 (new section)** documenting the - per-row entries semantic, the v166 probe outcomes, and the + per-row entries semantic, the v170 probe outcomes, and the connector-side mitigation. Same format as the existing §27 / §31 sections. -- **`scripts/wire-trace-v166.ts`** ships as the re-runnable probe +- **`scripts/wire-trace-v170.ts`** ships as the re-runnable probe harness — 5 probes covering the gap, cross-direction strictness, the `/people` endpoint shape, multi-party POST rejection, and the - person-partyId no-op. Same `ZZZ-V166-*` test record tagging and + person-partyId no-op. Same `ZZZ-V170-*` test record tagging and full cleanup pattern as v164 / v165. ## [1.6.5] — 2026-05-25 diff --git a/HOWTO.md b/HOWTO.md index dd89c61..c7e551a 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -11,7 +11,7 @@ npm install npm test ``` -529 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers: +530 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers: - **Per-tool unit tests** (e.g. `tests/parties.test.ts`): import the tool function, mock `undici.fetch`, assert on the URL, method, body, and response handling. Most tests live here. - **MCP-protocol integration tests** (`tests/mcp-integration.test.ts`): drive a real `McpServer` through the wire protocol via the SDK's in-memory transport pair, with `undici.fetch` still mocked. Catches the layer between "tool function works" and "MCP correctly registers and dispatches the tool". Includes the `get_attachment` content-type routing logic (which lives in `server.ts`, not the tool function). diff --git a/NOTES-ON-CAPSULE-API.md b/NOTES-ON-CAPSULE-API.md index 8137c64..aeb2cd1 100644 --- a/NOTES-ON-CAPSULE-API.md +++ b/NOTES-ON-CAPSULE-API.md @@ -1328,9 +1328,9 @@ describe `/{id}/entries` as "the list of entries linked to this party" without elaborating on the linked-persons case. Behaviour verified live, not from docs. -### Verified empirically (v1.6.6 wire-trace) +### Verified empirically (v1.7.0 wire-trace) -`scripts/wire-trace-v166.ts` ran five probes against a live tenant +`scripts/wire-trace-v170.ts` ran five probes against a live tenant to confirm the semantic before designing the mitigation: - **Probe 1** — note filed on a linked person → org's `/entries` @@ -1352,8 +1352,8 @@ to confirm the semantic before designing the mitigation: ### Connector-side mitigation -`list_party_entries.includeLinkedPersons` (added v1.6.6, optional, -default `false` — preserves the pre-v1.6.6 contract bit-for-bit). +`list_party_entries.includeLinkedPersons` (added v1.7.0, optional, +default `false` — preserves the pre-v1.7.0 contract bit-for-bit). When `true` and `partyId` is an organisation: 1. Fetch `/parties/{orgId}/people` to enumerate linked persons. @@ -1366,7 +1366,9 @@ When `true` and `partyId` is an organisation: is belt-and-suspenders). 4. Sort by `entryAt` descending (tie-break by `id` desc so the sort is total). -5. Slice the caller's `(page, perPage)` window over the merged feed. +5. Slice the caller's `(page, perPage)` window over the merged feed, + preserving `nextPage` when the merged candidate set or an upstream + `Link rel=next` says older entries remain. When `partyId` is a person, `includeLinkedPersons: true` is a no-op: the `/people` lookup returns empty (probe 5), and the connector @@ -1377,7 +1379,7 @@ default-on in callers without conditional logic. - [`src/tools/entries.ts`](src/tools/entries.ts) `listPartyEntries` — the `includeLinkedPersons` branch + `fanOutPartyEntries` helper. -- [`scripts/wire-trace-v166.ts`](scripts/wire-trace-v166.ts) — the +- [`scripts/wire-trace-v170.ts`](scripts/wire-trace-v170.ts) — the re-runnable probe harness. --- diff --git a/scripts/wire-trace-v166.ts b/scripts/wire-trace-v170.ts similarity index 96% rename from scripts/wire-trace-v166.ts rename to scripts/wire-trace-v170.ts index c6a6f9b..8bc52fd 100644 --- a/scripts/wire-trace-v166.ts +++ b/scripts/wire-trace-v170.ts @@ -1,5 +1,5 @@ /** - * Wire-trace probes for the v1.6.6 candidate: + * Wire-trace probes for the v1.7.0 candidate: * `list_party_entries.includeLinkedPersons` (org-timeline-includes-people). * * Question the workflow wants answered: when a user asks "what's the @@ -30,12 +30,12 @@ * person parties have no linked-people relationship in the data * model. Confirms how the API responds. * - * Pattern mirrors scripts/wire-trace-v164.ts and -v165.ts: ZZZ-V166-* + * Pattern mirrors scripts/wire-trace-v164.ts and -v165.ts: ZZZ-V170-* * labelled test records, full cleanup on exit, no tenant-specific * strings or IDs committed (everything discovered at runtime). Run * with: * - * CAPSULE_API_TOKEN= npx tsx scripts/wire-trace-v166.ts + * CAPSULE_API_TOKEN= npx tsx scripts/wire-trace-v170.ts */ import { fetch } from "undici"; @@ -84,7 +84,7 @@ function partiesOf(result: ApiResult): Array<{ id: number; type?: string }> { } async function main() { - const tag = `ZZZ-V166-${Date.now()}`; + const tag = `ZZZ-V170-${Date.now()}`; const createdParties: number[] = []; const createdEntries: number[] = []; @@ -187,11 +187,10 @@ async function main() { console.log(" → does Capsule file once (under participants array) or twice?"); console.log("========================================="); - // Capsule allows entries to have multiple participants via the - // `parties` array on POST /entries. Probe whether such an entry - // surfaces in both per-party endpoint lists and whether the id - // is the same (single entry, multiple references) or different - // (one filing per participant). + // Probe whether Capsule accepts a multi-party `parties` array on + // POST /entries. The live v1.7.0-candidate run rejected this shape with + // 422, which is the evidence behind the connector's single-row + // filing assumption. const sharedNote = await call("POST", "/entries", { entry: { parties: [{ id: orgId }, { id: personId }], diff --git a/src/tools/entries.ts b/src/tools/entries.ts index 81e6dde..b511050 100644 --- a/src/tools/entries.ts +++ b/src/tools/entries.ts @@ -23,13 +23,18 @@ export const listPartyEntriesSchema = z.object({ .describe( "When true AND `partyId` is an ORGANISATION, also include entries filed against the organisation's linked people (the persons whose `organisation` field references this org). The connector enumerates linked persons via `GET /parties/{orgId}/people`, fans out `GET /parties/{personId}/entries` in parallel (concurrency-capped, default 5 / configurable via `CAPSULE_MCP_BATCH_CONCURRENCY`), and merges into a single feed sorted by `entryAt` descending, deduped by entry id. " + "Default is `false` — single GET, existing behaviour unchanged. " + - "WHY THIS FLAG EXISTS: Capsule's API files each entry against exactly one party row (verified v1.6.6 wire-trace probe 4 — POST /entries rejects multi-party bodies with 422 'entry must be linked to either a party, opportunity or kase'). For an organisation with multiple contacts, captured emails almost always land on a person row, not the org. As a result, `list_party_entries(orgId)` with `includeLinkedPersons: false` will miss recent customer-facing email — even though the org's own `lastContactedAt` is updated by the activity. This flag is the correct call for any 'what's new with $ORG?' question. " + + "WHY THIS FLAG EXISTS: Capsule's API files each entry against exactly one party row (verified v1.7.0 wire-trace probe 4 — POST /entries rejects multi-party bodies with 422 'entry must be linked to either a party, opportunity or kase'). For an organisation with multiple contacts, captured emails almost always land on a person row, not the org. As a result, `list_party_entries(orgId)` with `includeLinkedPersons: false` will miss recent customer-facing email — even though the org's own `lastContactedAt` is updated by the activity. This flag is the correct call for any 'what's new with $ORG?' question. " + "WHEN `partyId` IS A PERSON: silently no-op — persons have no linked-people relationship in Capsule's data model, so the flag is functionally inert (the connector still issues a cheap `/people` check; the response is empty). " + - "LATENCY: 1 + N round trips for an org with N linked people, concurrency-capped (typical: 2-3 waves for N=10). Use `includeLinkedPersons: false` for fast pre-screen reads where you only need the org-row entries (e.g. invoice/contract notes that are typically filed at the org level). " + - "PAGINATION CAVEAT: `page` and `perPage` apply to the MERGED window. The connector fetches `perPage` entries from each party then slices the caller's window; for deep pagination (`page > 1` with a large multi-person org) the slice may be approximate — the latest entries are correct, but ordering deeper into the feed can lose entries from parties with many records. For LLM-driven 'what's the latest' queries (the typical use), this is invisible.", + "LATENCY: 1 + N round trips for an org with N linked people, concurrency-capped (typical: 2-3 waves for N=10). Linked-person enumeration reads the first 100 linked people; use list_employees for explicit pagination when an organisation has more contacts than that. Use `includeLinkedPersons: false` for fast pre-screen reads where you only need the org-row entries (e.g. invoice/contract notes that are typically filed at the org level). " + + "PAGINATION CAVEAT: `page` and `perPage` apply to the MERGED window. The connector fetches enough entries from each party to cover the requested merged window (up to Capsule's per-party cap of 100) then slices the caller's window; very deep pagination with a large multi-person org can still be approximate. For LLM-driven 'what's the latest' queries (the typical use), this is invisible.", ), }); +interface PartyEntriesPage { + entries: unknown[]; + nextPage: number | undefined; +} + /** * Fetch `entries` arrays for multiple party ids in parallel, * concurrency-capped by `getBatchConcurrency()`. Throws on the first @@ -40,9 +45,9 @@ async function fanOutPartyEntries( partyIds: number[], embed: string | undefined, perPage: number, -): Promise { +): Promise { const concurrency = getBatchConcurrency(); - const results: unknown[][] = new Array(partyIds.length); + const results: PartyEntriesPage[] = new Array(partyIds.length); let cursor = 0; async function worker(): Promise { while (true) { @@ -50,12 +55,15 @@ async function fanOutPartyEntries( cursor += 1; if (i >= partyIds.length) return; const id = partyIds[i]!; - const { data } = await capsuleGet<{ entries: unknown[] }>(`/parties/${id}/entries`, { - embed, - page: 1, - perPage, - }); - results[i] = data.entries; + const { data, nextPage } = await capsuleGet<{ entries: unknown[] }>( + `/parties/${id}/entries`, + { + embed, + page: 1, + perPage, + }, + ); + results[i] = { entries: data.entries, nextPage }; } } const workers: Promise[] = []; @@ -66,11 +74,35 @@ async function fanOutPartyEntries( return results; } +function mergedTimelineCandidatePerParty(page: number, perPage: number): number { + return Math.min(page * perPage, 100); +} + +function mergedTimelineNextPage( + page: number, + perPage: number, + mergedLength: number, + upstreamHasNextPage: boolean, +): number | undefined { + const requestedWindowEnd = page * perPage; + if (mergedLength > requestedWindowEnd) return page + 1; + + // When the requested window fits within the per-party fetch cap, + // an upstream Link rel=next means there are older entries beyond + // our candidate set even if the merged slice length is exactly + // `perPage`. Preserve that signal instead of falsely ending the + // merged feed at page 1. + const coveredRequestedWindow = requestedWindowEnd <= 100; + if (coveredRequestedWindow && upstreamHasNextPage) return page + 1; + + return undefined; +} + export async function listPartyEntries(input: z.infer) { const { partyId, embed, page, perPage, includeLinkedPersons } = input; // Fast path: default behaviour, single GET — preserves the - // pre-v1.6.6 contract bit-for-bit. + // pre-v1.7.0 contract bit-for-bit. if (!includeLinkedPersons) { const { data, nextPage } = await capsuleGet<{ entries: unknown[] }>( `/parties/${partyId}/entries`, @@ -81,7 +113,8 @@ export async function listPartyEntries(input: z.infer100 linked persons on a single org see partial - // coverage. Documented in the schema description. + // coverage. The schema description calls this out and points to + // list_employees for explicit linked-person pagination. const { data: peopleData } = await capsuleGet<{ parties?: { id: number }[] }>( `/parties/${partyId}/people`, { page: 1, perPage: 100 }, @@ -99,20 +132,24 @@ export async function listPartyEntries(input: z.infer` dedup is // defensive against captured-email SMTP routing rules we can't // simulate in the probe and against any future API change. const seen = new Set(); const merged: Array<{ id: number; entryAt?: string }> = []; - for (const arr of perPartyEntries) { - for (const raw of arr) { + for (const { entries } of perPartyPages) { + for (const raw of entries) { const e = raw as { id: number; entryAt?: string }; if (typeof e?.id !== "number") continue; if (seen.has(e.id)) continue; @@ -134,7 +171,12 @@ export async function listPartyEntries(input: z.infer start + perPage ? page + 1 : undefined; + const nextPage = mergedTimelineNextPage( + page, + perPage, + merged.length, + perPartyPages.some((p) => p.nextPage !== undefined), + ); return { entries: slice, ...(nextPage !== undefined ? { nextPage } : {}) }; } diff --git a/tests/entries.test.ts b/tests/entries.test.ts index 83f02b0..82fb8d5 100644 --- a/tests/entries.test.ts +++ b/tests/entries.test.ts @@ -30,10 +30,10 @@ describe("listPartyEntries", () => { expect(result.entries).toHaveLength(2); }); - // ── v1.6.6: includeLinkedPersons ──────────────────────────────────── + // ── v1.7.0: includeLinkedPersons ──────────────────────────────────── // // Capsule files each entry against exactly one party row (verified - // v1.6.6 wire-trace probe 4 — POST /entries rejects multi-party). + // v1.7.0 wire-trace probe 4 — POST /entries rejects multi-party). // For an org with multiple contacts, customer-facing email lands on // person rows; the org's own /entries response misses it. The // includeLinkedPersons flag tells the connector to enumerate linked @@ -48,7 +48,7 @@ describe("listPartyEntries", () => { // Critical canary: when includeLinkedPersons is omitted, the // connector MUST NOT issue the /people lookup. That's the - // pre-v1.6.6 contract. + // pre-v1.7.0 contract. expect(vi.mocked(fetch).mock.calls).toHaveLength(1); expect(String(vi.mocked(fetch).mock.calls[0]![0])).toMatch(/\/parties\/7\/entries/); }); @@ -112,7 +112,7 @@ describe("listPartyEntries", () => { it("includeLinkedPersons + person partyId: no-op (no fan-out)", async () => { // A person partyId has no linked-people in Capsule's data model — - // /people returns an empty array (verified v1.6.6 wire-trace + // /people returns an empty array (verified v1.7.0 wire-trace // probe 5). Connector short-circuits to single GET; flag is // functionally inert. mockFetch(200, { parties: [] }); // /people on a person @@ -134,7 +134,7 @@ describe("listPartyEntries", () => { // If Capsule's SMTP ingestion ever files the same captured-email // entry against both an org and a linked person, naive concat // would surface a duplicate. The connector dedups by entry.id. - // The probe (v166 #4) showed POST rejects multi-party, but + // The probe (v170 #4) showed POST rejects multi-party, but // captured emails go through a separate code path we can't // simulate — dedup is belt-and-suspenders. mockFetch(200, { parties: [{ id: 8 }] }); @@ -210,6 +210,37 @@ describe("listPartyEntries", () => { // 5 candidates, slice goes 0..2 → nextPage signals more remain. expect((result as { nextPage?: number }).nextPage).toBe(2); }); + + it("preserves upstream nextPage when the merged page is exactly full", async () => { + // Regression: if one linked person's first page is exactly full + // and Capsule sends Link rel=next, the merged result still has a + // next page even though merged.length === perPage. + mockFetch(200, { parties: [{ id: 8 }] }); + mockFetch(200, { entries: [] }); + mockFetch( + 200, + { + entries: [ + { id: 11, type: "email", entryAt: "2026-05-27T11:00:00Z" }, + { id: 10, type: "email", entryAt: "2026-05-27T10:00:00Z" }, + ], + }, + { + Link: '; rel="next"', + }, + ); + + const { listPartyEntries } = await import("../src/tools/entries.js"); + const result = await listPartyEntries({ + partyId: 7, + page: 1, + perPage: 2, + includeLinkedPersons: true, + }); + + expect(result.entries.map((e: { id: number }) => e.id)).toEqual([11, 10]); + expect((result as { nextPage?: number }).nextPage).toBe(2); + }); }); describe("listOpportunityEntries", () => {