mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path
> - Paperclip helps people manage AI agents that do work.
> - Native agents use tools to read and write task documents.
> - A document key is a stable identifier. The service trims it and
converts it to lowercase before it checks the key.
> - The tool schemas accepted filenames and long keys that the service
rejects.
> - This pull request makes ordinary document tool schemas match the
existing service contract.
> - Agents can now see the key rules before they call the tools.
## Linked Issues or Issue Description
**What happened?**
The schemas for `read_document`, `write_document`, and
`list_document_revisions` accepted `reconciliation.md` and keys longer
than 64 characters after normalization. The task document service
rejects these values. The descriptions did not explain that the key is
an identifier rather than a filename.
**Expected behavior**
The model catalog and the live and scenario tool definitions must
describe the existing document key rules. Values accepted after trimming
and conversion to lowercase must remain compatible. Historical
case-document keys use a separate contract.
**Steps to reproduce**
Validate `{ "key": "reconciliation.md" }` against the previous
`read_document` schema. It passes. Call the document service with that
key. It rejects the key before it reads the database. The added
regression repeats this comparison for all three tools.
## What Changed
- Apply the same document-key pattern to `read_task_document` and
`write_task_document` at the API bridge. Keep their existing raw-input
limit of 100 characters; ordinary tools keep 120. Export the shared
schema through the existing Runner package and server vendor boundary.
Route permissions, normalization and document locks stay with their
current owners.
- Add one shared schema for ordinary task-document keys. Keep the
existing 120-character raw input cap and the 64-character normalized
identifier limit.
- Preserve uppercase input, ECMAScript trim whitespace, and the Kelvin
sign, which converts to ASCII `k`.
- Apply the schema to the model catalog and the three live and scenario
definitions. Regenerate the catalogs, seeded fixture identity, and
fixture manifest.
- Test schema parity against the real task document service. Keep
historical case keys and the backend validator unchanged.
## Verification
- Current-head required CI is green at
`e67d3f1f49393660f0c70b3d1d136e6f86af821d`: 56 successful checks, four
intentional skips, and two neutral checks. [CI attempt
2](https://github.com/paperclipai/paperclip/actions/runs/41715924252)
passed after one targeted rerun of five jobs interrupted by the same
runner shutdown; the original interruption is retained. Fresh Greptile
and Paperclip reviews are both 5/5 with no unresolved review threads.
Hosted recovery remains unverified until automatic deployment and
replay.
- Normal master refresh to `e67d3f1f49393660f0c70b3d1d136e6f86af821d`
includes the already merged external-object startup fixture correction.
The previous required canary failed downloading
`mdast-util-gfm-table@2.0.1` with npm E404; later registry metadata and
tarball HEAD both returned 200. A separate general shard failed the
ten-thousand-future-objects startup assertion. Both original failures
remain recorded; neither was bypassed or retried. At the refreshed
baseline, 122 document/bridge cases and the exact large-object startup
case pass locally. Six document cases skip by platform. Fresh required
CI and reviews must pass on this head.
- The current-head Paperclip review found the same mismatch in the
task-scoped bridge. Before the correction, the real advertised schemas
failed 22 parity cases while 87 controls passed. After the correction
and a normal master refresh, the document-key and bridge suites pass 122
cases, with six platform-specific skips. The tests compare advertised
JSON Schema, runtime Zod validation, and the actual normalized document
service. They retain the bridge raw 100/101 boundary and historical
case-key controls.
- Runner TypeScript build, all server typecheck layers, and the actual
production Runner and server build pass. The final server build stamp
binds commit `80c880709c5f5630799fb8a48d54ff11ebc16610`. Full
current-head CI and fresh review are required after the correction. The
earlier CI run was externally cancelled and rerun; its cancellation
remains recorded.
- The regression failed in 33 of 64 cases before the fix. The remaining
cases passed as controls.
- The fixed regression passes all 67 cases, including every ECMAScript
trim character.
- The four focused Runner contract suites pass: 116 tests. They cover
all three tool surfaces and existing case behavior.
- Generated catalogs and manifest checks pass. Module-boundary and diff
checks pass.
- All server production typecheck layers pass. The test layer passes
after correcting the new test’s Ajv import. The full production server
and Runner build passes at a3e6191590.
- Full repository checks run in required CI. No hosted replay or
provider behavior claim is made.
## Risks
- The tool schema now rejects unsupported keys before the service does.
Older callers that used invalid filenames must use a stable key such as
`reconciliation`.
- The compatibility pattern must track the service normalization rules.
The regression checks the real service and includes Unicode
compatibility controls.
- No database, authorization, document revision, idempotency, or
case-document rules change.
## Model Used
OpenAI `gpt-6.1-sol`, Extra High reasoning. The model used code
execution, repository tools, and test output. The host does not expose
the context window size.
## 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
- [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
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
110 lines
5.5 KiB
TypeScript
110 lines
5.5 KiB
TypeScript
import { Ajv2020 } from "ajv/dist/2020.js";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import type { Db } from "@paperclipai/db";
|
||
import { issueDocumentKeySchema } from "@paperclipai/shared";
|
||
import { documentService } from "../documents.js";
|
||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||
import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions } from "./runner-bridge-tools.js";
|
||
|
||
const binding = {
|
||
companyId: "00000000-0000-4000-8000-000000000001",
|
||
agentId: "00000000-0000-4000-8000-000000000002",
|
||
issueId: "00000000-0000-4000-8000-000000000003",
|
||
runId: "00000000-0000-4000-8000-000000000004",
|
||
casesEnabled: true,
|
||
};
|
||
const trimWhitespace = "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff";
|
||
const keys = [
|
||
"reconciliation",
|
||
"reconciliation.md",
|
||
"a".repeat(64),
|
||
"a".repeat(65),
|
||
"Reconciliation",
|
||
`${trimWhitespace}${"a".repeat(64)}${trimWhitespace}`,
|
||
"K",
|
||
"K".repeat(64),
|
||
"K".repeat(65),
|
||
" a-b_1 ",
|
||
`\ufeff\u00a0\u2003${"a".repeat(64)}\r\n`,
|
||
" ".repeat(56) + "a".repeat(64),
|
||
"",
|
||
" \ufeff",
|
||
"../document",
|
||
"a/b",
|
||
"Research:v1.notes",
|
||
"\u0085key\u0085",
|
||
"_key",
|
||
"-key",
|
||
"a\u0000",
|
||
];
|
||
const operations = ["read_document", "write_document", "list_document_revisions"] as const;
|
||
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: false });
|
||
const definitions = new PaperclipRunnerToolAuthority({} as Db, binding).definitions();
|
||
const bridgeDefinitions = runnerBridgeDefinitions({ workspace: false, skills: false, api: true, mode: "standard" });
|
||
const bridgeKeys = [...keys, " ".repeat(36) + "a".repeat(64), " ".repeat(37) + "a".repeat(64)];
|
||
function input(operation: string, key: string) {
|
||
return operation === "write_document"
|
||
? { idempotencyKey: "synthetic", key, title: "Synthetic", body: "Synthetic", baseRevisionId: null }
|
||
: { key };
|
||
}
|
||
|
||
describe("ordinary task-document key contract parity", () => {
|
||
it.each(["read_task_document", "write_task_document"].flatMap(operation => bridgeKeys.map(key => [operation, key] as const)))(
|
||
"%s advertises and validates the real service key contract for %j", async (operation, key) => {
|
||
const validServiceKey = issueDocumentKeySchema.safeParse(key.trim().toLowerCase()).success;
|
||
const expected = key.length <= 100 && validServiceKey;
|
||
const args = operation === "read_task_document" ? { taskId: binding.issueId, key }
|
||
: { taskId: binding.issueId, key, title: "Synthetic", body: "Synthetic", baseRevisionId: null };
|
||
const tool = bridgeDefinitions.find(definition => definition.name === operation)!;
|
||
expect(ajv.validate(tool.inputSchema as object, args), JSON.stringify(ajv.errors)).toBe(expected);
|
||
expect(RUNNER_BRIDGE_SCHEMAS.get(operation)!.safeParse(args).success).toBe(expected);
|
||
const select = vi.fn(() => { throw new Error("synthetic_database_boundary"); });
|
||
const service = documentService({ select } as unknown as Db);
|
||
const call = operation === "read_task_document" ? service.getIssueDocumentByKey(binding.issueId, key)
|
||
: service.upsertIssueDocument({ issueId: binding.issueId, key, title: "Synthetic", format: "markdown", body: "Synthetic", baseRevisionId: null });
|
||
await expect(call).rejects.toThrow(validServiceKey ? "synthetic_database_boundary" : "Invalid document key");
|
||
expect(select).toHaveBeenCalledTimes(validServiceKey ? 1 : 0);
|
||
},
|
||
);
|
||
it.each(operations.flatMap(operation => keys.map(key => [operation, key] as const)))(
|
||
"%s matches real normalization for %j",
|
||
async (operation, key) => {
|
||
const expected = issueDocumentKeySchema.safeParse(key.trim().toLowerCase()).success;
|
||
const tool = definitions.find(definition => definition.name === operation)!;
|
||
expect(ajv.validate(tool.inputSchema as object, input(operation, key)), JSON.stringify(ajv.errors)).toBe(expected);
|
||
const select = vi.fn(() => {
|
||
throw new Error("synthetic_database_boundary");
|
||
});
|
||
const service = documentService({ select } as unknown as Db);
|
||
const call = operation === "read_document"
|
||
? service.getIssueDocumentByKey(binding.issueId, key)
|
||
: operation === "list_document_revisions"
|
||
? service.listIssueDocumentRevisions(binding.issueId, key)
|
||
: service.upsertIssueDocument({
|
||
issueId: binding.issueId,
|
||
key,
|
||
title: "Synthetic",
|
||
format: "markdown",
|
||
body: "Synthetic",
|
||
baseRevisionId: null,
|
||
});
|
||
await expect(call).rejects.toThrow(expected ? "synthetic_database_boundary" : "Invalid document key");
|
||
expect(select).toHaveBeenCalledTimes(expected ? 1 : 0);
|
||
},
|
||
);
|
||
|
||
it.each(operations)("%s retains the existing raw-input cap independently of normalization", operation => {
|
||
const key = " ".repeat(57) + "a".repeat(64);
|
||
expect(issueDocumentKeySchema.safeParse(key.trim().toLowerCase()).success).toBe(true);
|
||
const tool = definitions.find(definition => definition.name === operation)!;
|
||
expect(ajv.validate(tool.inputSchema as object, input(operation, key))).toBe(false);
|
||
});
|
||
|
||
it("keeps historical case keys distinct from ordinary task-document keys", () => {
|
||
const tool = definitions.find(definition => definition.name === "read_case")!;
|
||
for (const documentKey of ["Research:v1.notes", "a".repeat(120)]) {
|
||
expect(ajv.validate(tool.inputSchema as object, { section: "document", documentKey }), JSON.stringify(ajv.errors)).toBe(true);
|
||
}
|
||
});
|
||
});
|