mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
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:
8 files changed
+84
-12
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user