fix(runner): paginate native task searches without hidden truncation (#18282)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native agents search tasks to find work that already exists.
> - The search schema permits 200 results, but the server returns at
most 100.
> - The response does not tell the agent that more results exist.
> - This pull request adds pages and reports the page size and
continuation cursor.
> - Agents can read the full accessible inventory before they decide
that a task does not exist.

## Linked Issues or Issue Description

**What happened?**

A native `search_tasks` request with `limit: 200` returns only 100
tasks. Historical scans recovered 167 and 173 tasks with status
partitions. This proves missing inventory and extra work. It does not
prove duplicate task creation.

**Expected behavior**

Honor the declared limit. Report more results and give the caller a
continuation cursor.

**Steps to reproduce**

Create 205 matching tasks in a test company. Call `search_tasks` with
`limit: 200`. The baseline returns 100. The fixed path returns 200, then
five when the caller uses `nextCursor`.

**Paperclip version or commit**

Baseline: `81ebe479e8db8a3d59c86cdfb894bd917734492f`.

Related changes: #16979 changes UI status filters before pagination.
#13699 adds public task URLs. Neither repairs this native limit. An
exact `search_tasks` plus pagination PR search returned no result.

Pattern: [Task searches omit cases beyond the first
hundred](https://www.braintrust.dev/app/Paperclip/p/Paperclip%20Staging/patterns/c20462d6-05fd-4862-bfbd-7bec1f2bc159).

### Hypothesis and thesis for a fix

The native authority uses `boundedLimit` with its default maximum of
100. The tool schema permits 200. The response contains only `tasks`.
The raw request and result counts agree with this cause.

Fix the native authority and its canonical tool contract. Keep the
existing `tasks` field. Add `limit`, `returnedCount`, `hasMore` and
`nextCursor`. Use ascending task IDs so task activity does not move
results between pages. Apply current company, privacy and Muse read
checks before each page. A cursor grants no access. Callers must keep
the same filters and read every page. Concurrent task changes require a
fresh scan.

## What Changed

- Honor the native search limit of 1 to 200, with a default of 50.
- Add ID cursor pagination and page metadata.
- Describe complete inventory reads in the tool contract and access map.
- Refresh the generated semantic contracts and seeded catalog identity.
- Add regression coverage for complete inventories and access-filtered
pages.

## Verification

- Red: the database integration test requested 200 of 205 persisted
tasks. Baseline returned 100.
- Green: the same regression returns pages of 200 and five without
duplicate IDs.
- The native authority and Muse suites cover company exclusion, status
filters, empty pages, invalid cursors and readable-only metadata.
- Command: `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/paperclip-runner-tool-authority.test.ts
src/services/native-runtime/muse-native-visibility.test.ts`.
- The test uses the real run-bound native authority and disposable
PostgreSQL. It does not use an external model or change a deployed
scanner.
- Both native authority and Muse suites pass: 71 tests. Server direct
`tsc --noEmit`, Runner `typecheck:typescript`, `node
scripts/check-maps.mjs` and generated semantic contract checks pass.
- Full typecheck and build cannot finish here because `cargo` is absent.
The full local test command was stopped after about five minutes when
the complete CI matrix was running. It has no complete verdict.
Current-head CI passed, including typecheck, build, all
general/serialized/Runner test suites and E2E. Greptile is 5/5 with no
actionable findings at `392cb7424a1fa0ec73401d5e8a10a22b504fde32`.
Optional Storybook and manual Docker workflows were skipped and are not
executed coverage. The success-example correction also passed all 82
contract tests locally.

## Risks

Task order changes to ascending ID. Pagination is not a snapshot. New or
changed tasks can require a fresh scan. Each page retains the existing
full company-list read and Muse authorization cost. This change adds no
database query loop or new background work. Existing callers can still
read `tasks`, but must follow `nextCursor` for a complete inventory. No
scanner implementation was supplied. No migration or deployment is
included.

## Model Used

OpenAI GPT-6 through Codex, with tool use and code execution. The exact
provider deployment ID and context limit are not exposed in this
session.

## 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)
- [ ] 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

The task requires the supplied branch to stay unchanged. All applicable
CI and review checks pass at the current head. Unmerged and not
deployed.

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-10-30 09:23:10 -05:00
1 parent 6da112ab40
commit e345b9217d
8 files changed
+84 -12

No files matched your search

+5 -3
View File
@@ -72,15 +72,17 @@ Read task collections, navigation projections and blocked counts; distinguish do
API: `present` — GET /api/companies/{companyId}/issues; optional view=compact.
Agent access: `partial` — REST/call_api full query; CLI issue list exposes status/agent/project and local --match on one fetched page; native search_tasks query up to 500 characters, statuses, and a declared limit 1..200; production clamps the limit to 1..100 (default 50), without full paging/project/label/workspace knobs.
Agent access: `partial` — REST/call_api full query; CLI issue list exposes status/agent/project and local --match on one fetched page; native search_tasks query up to 500 characters, statuses, limit 1..200 (default 50), and a nextCursor in ascending task-ID order. Responses expose effective limit, returnedCount and hasMore. Exhaust nextCursor with the same filters before treating an inventory as complete. Each page checks current access; concurrent task changes require a fresh scan. Project/label/workspace knobs remain REST-only.
Policy: Company/read privacy filtering and inaccessible edge redaction; task-bridge company list denied. Default 500, maximum 1000, offset>=0, sortField updated|id, sortDir asc|desc, afterId. Supports status/assignee/participant/project/workspace/parent/descendant/label/origin/text/date and routine/plugin/blocker query groups. includeAgentCreatedTasks=false filters independent roots before search/pagination; omission retains agent/API behavior. includeParticipatedTasks and user me filters require board identity. Compact ETag is actor-private; 429 is not empty results.
Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7a`; [issues.ts](../../server/src/routes/issues.ts), [issues.ts](../../server/src/services/issues.ts), [paperclip-runner-tool-authority.ts](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts), [issue.ts](../../cli/src/commands/client/issue.ts), [issues-list-query-parsing.test.ts](../../server/src/__tests__/issues-list-query-parsing.test.ts), [issues-creation-visibility.test.ts](../../server/src/__tests__/issues-creation-visibility.test.ts), [issue-list-assignee-filter-routes.test.ts](../../server/src/__tests__/issue-list-assignee-filter-routes.test.ts), [private-json-etag.test.ts](../../server/src/__tests__/private-json-etag.test.ts). No live execution claimed.
Verification: `not-run` — Compare human/agent/routine/channel tasks, list/compact/full filters and ID pagination without duplicates or inaccessible rows. Reject invalid boolean/date/sort/limit and agent human-only/task-bridge query. Preserve failed/429 result visibly. Request native limit 200 and confirm the production result contains at most 100 tasks.
Verification: `not-run` — Compare human/agent/routine/channel tasks, list/compact/full filters and ID pagination without duplicates or inaccessible rows. Reject invalid boolean/date/sort/limit and agent human-only/task-bridge query. Preserve failed/429 result visibly. Request native limit 200 with more than 200 matches and follow nextCursor to exhaustion; require all accessible IDs without duplicates and accurate page metadata.
Gaps: Bounded native search and one-page CLI local match are not complete company-search substitutes.
Native verification: disposable PostgreSQL integration through the run-bound `PaperclipRunnerToolAuthority.execute` entry point covers 205 persisted tasks, 200+5 pages, terminal/empty results, status filters, cursor validation, foreign-company exclusion and Muse readability before pagination. This does not establish deployed recovery or REST verification.
Gaps: One-page CLI local match is not a complete company-search substitute. Native pagination is not a snapshot across concurrent changes.
### `read-task-and-summaries`
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:695e4e81354298ad22b3321573dba2d811a07145ccc1826eb795e26ba95a0896",
"catalogSha256": "sha256:015531b07c9906c6edfa6029890cc7b2c69435c1c0c37432ffe21459e6f15c43",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -180,7 +180,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "4e970b2c9ad58399a6874a56fee35c76f8aec5554488ad17143c4578269b0222",
"sha256": "0b878d30ad4b5514a14f7387c3792662cbfeebc7149188ffe304197e41c20e40",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -31,7 +31,7 @@ export const searchTasksAction = {
},
"documentation": {
"title": "Search company tasks",
"description": "Search tasks by text and status within the run company.",
"description": "Search tasks by text and status within the run company in ascending task-ID order. Returns tasks, effective limit, returnedCount, hasMore and nextCursor. Follow nextCursor with the same query and statuses until null before treating an inventory as complete. Each page checks current access; concurrent task changes require a fresh scan.",
"note": null
},
"examples": {
@@ -42,7 +42,7 @@ export const searchTasksAction = {
"success": {
"ok": true,
"operationId": "search_tasks",
"result": {}
"result": { "tasks": [], "limit": 50, "returnedCount": 0, "hasMore": false, "nextCursor": null }
}
},
"live": {
@@ -52,7 +52,7 @@ export const searchTasksAction = {
"operationId": "search_tasks",
"version": 1,
"title": "Search company tasks",
"description": "Search tasks by text and status within the run company.",
"description": "Search tasks by text and status within the run company in ascending task-ID order. Returns tasks, effective limit, returnedCount, hasMore and nextCursor. Follow nextCursor with the same query and statuses until null before treating an inventory as complete. Each page checks current access; concurrent task changes require a fresh scan.",
"exposure": "optional",
"requiredClaims": [
"discovery:tasks:read"
@@ -91,6 +91,11 @@ export const searchTasksAction = {
"minimum": 1,
"maximum": 200,
"default": 50
},
"cursor": {
"type": "string",
"format": "uuid",
"description": "The nextCursor from the previous page. Keep query and statuses unchanged. Omit for the first page."
}
},
"required": [],
@@ -98,6 +103,14 @@ export const searchTasksAction = {
},
"outputSchema": {
"type": "object",
"properties": {
"tasks": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
"limit": { "type": "integer", "minimum": 1, "maximum": 200 },
"returnedCount": { "type": "integer", "minimum": 0, "maximum": 200 },
"hasMore": { "type": "boolean" },
"nextCursor": { "type": ["string", "null"] }
},
"required": ["tasks", "limit", "returnedCount", "hasMore", "nextCursor"],
"additionalProperties": true
}
}
@@ -89,7 +89,16 @@ describe("Muse native visibility intersection", () => {
else expect(ids).toContain(f.outside.id);
if(shape==="search_tasks"){
const outsideOnly=await f.authority.execute({tool:"search_tasks",callId:"outside-only",arguments:{query:"MUSE_READ_SCOPE_OUTSIDE",limit:1}});
expect(outsideOnly).toMatchObject({tasks:restricted?[]:[{id:f.outside.id}]});
expect(outsideOnly).toMatchObject({tasks:restricted?[]:[{id:f.outside.id}],returnedCount:restricted?0:1,hasMore:false,nextCursor:null});
const page=await f.authority.execute({tool:"search_tasks",callId:"authorized-page",arguments:{query:"MUSE_READ_SCOPE",limit:1}}) as {tasks:Array<{id:string}>;hasMore:boolean;nextCursor:string|null};
expect(page.tasks).toHaveLength(1);
expect(page.hasMore).toBe(!restricted);
if(restricted)expect(page.nextCursor).toBeNull();
else {
const next=await f.authority.execute({tool:"search_tasks",callId:"authorized-next-page",arguments:{query:"MUSE_READ_SCOPE",limit:1,cursor:page.nextCursor}}) as {tasks:Array<{id:string}>;hasMore:boolean;nextCursor:string|null};
expect([...page.tasks,...next.tasks].map(task=>task.id).sort()).toEqual([f.allowed.id,f.outside.id].sort());
expect(next).toMatchObject({hasMore:false,nextCursor:null});
}
}
});
it("retains the child search fallback when the authorized child follows a capped denied prefix",async()=>{
@@ -454,6 +454,41 @@ describe("PaperclipRunnerToolAuthority", () => {
}
});
it("paginates complete native task inventories beyond one hundred results", async () => {
const ids = Array.from({ length: 205 }, () => randomUUID()).sort();
const foreignCompanyId = randomUUID();
const foreignId = randomUUID();
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign inventory", issuePrefix: "FINV" });
await db.insert(issues).values({ id: foreignId, companyId: foreignCompanyId, title: "synthetic-inventory-key foreign canary", status: "done" });
await db.insert(issues).values(ids.map((id, index) => ({
id, companyId, title: `Inventory pagination ${index}`,
description: "synthetic-inventory-key", status: index < 200 ? "done" : "blocked",
})));
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const search = (arguments_: Record<string, unknown>) => authority.execute({
tool: "search_tasks", callId: randomUUID(), arguments: { query: "synthetic-inventory-key", ...arguments_ },
}) as Promise<{ tasks: Array<{ id: string }>; limit: number; returnedCount: number; hasMore: boolean; nextCursor: string | null }>;
try {
const first = await search({ limit: 200 });
expect(first.tasks).toHaveLength(200);
expect(JSON.stringify(first)).not.toContain(foreignId);
expect(first).toMatchObject({ limit: 200, returnedCount: 200, hasMore: true, nextCursor: ids[199] });
expect(first.tasks.map(task => task.id)).toEqual(ids.slice(0, 200));
const last = await search({ limit: 200, cursor: first.nextCursor });
expect(last.tasks.map(task => task.id)).toEqual(ids.slice(200));
expect(last).toMatchObject({ limit: 200, returnedCount: 5, hasMore: false, nextCursor: null });
expect(new Set([...first.tasks, ...last.tasks].map(task => task.id)).size).toBe(205);
expect(await search({ cursor: ids[204] })).toMatchObject({ tasks: [], limit: 50, returnedCount: 0, hasMore: false, nextCursor: null });
expect(await search({ statuses: ["blocked"], limit: 200 })).toMatchObject({ tasks: ids.slice(200).map(id => ({ id })), returnedCount: 5, hasMore: false });
expect(await search({})).toMatchObject({ limit: 50, returnedCount: 50, hasMore: true });
expect((await search({ limit: 200, cursor: ids[199]!.toUpperCase() })).tasks.map(task => task.id)).toEqual(ids.slice(200));
await expect(search({ cursor: "invalid" })).rejects.toThrow("paperclip_runner_tool_input_invalid");
} finally {
await db.delete(issues).where(inArray(issues.id, [...ids, foreignId]));
await db.delete(companies).where(eq(companies.id, foreignCompanyId));
}
});
it("returns public task URLs from the current claimed origin in context and search results", async () => {
const origin = vi.spyOn(cloudIdentity, "runtimeCanonicalOrigin").mockReturnValue("https://board.example");
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
@@ -885,8 +885,21 @@ export class PaperclipRunnerToolAuthority {
&& (!statuses || statuses.size === 0 || statuses.has(task.status))
);
const readable = this.binding.museRuntime ? await this.#readableMuseTaskIds(context.run, matching.map(task => task.id)) : null;
return { tasks: matching.filter(task => !readable || readable.has(task.id))
.slice(0, boundedLimit(input.limit)).map(redactedTask) };
const limit = boundedLimit(input.limit, 50, 200);
const cursor = input.cursor === undefined ? null : requiredUuid(input.cursor).toLowerCase();
// IDs keep page order stable when task activity or status changes. Every
// page still applies current company, privacy and Muse read authority.
const remaining = matching.filter(task => (!readable || readable.has(task.id)) && (!cursor || task.id > cursor))
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
const page = remaining.slice(0, limit);
const hasMore = remaining.length > limit;
return {
tasks: page.map(redactedTask),
limit,
returnedCount: page.length,
hasMore,
nextCursor: hasMore ? page[page.length - 1]!.id : null,
};
}
case "list_documents":
return { documents: await this.#visibleMuseDocuments(context.issue,