Files
PaperClipAI/server/src/services/native-runtime/task-document-key-contract.test.ts
T
DottaandPaperclip da77c1bcfa fix(runner): align ordinary document key contracts (#18824)
## 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>
2026-11-03 15:21:45 -06:00

110 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
});
});