mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +02:00
fix(dot): synchronize human assignment and preserve Unicode pages
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
155c825315
commit
eefd3f22ea
5 files changed
+58
-5
No files matched your search
@@ -2149,7 +2149,15 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"assigneeActorId": {
|
||||
"description": "Optional actor assignee.",
|
||||
"description": "Optional actor assignee. Mutually exclusive with assigneeUserId.",
|
||||
"maxLength": 200,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"assigneeUserId": {
|
||||
"description": "Company person ID from list_people. Mutually exclusive with assigneeActorId.",
|
||||
"maxLength": 200,
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -49,6 +49,18 @@ describe("semantic action catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("advertises human assignment in both task creation contracts", () => {
|
||||
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
|
||||
for (const schema of [createTaskAction.live.descriptor.inputSchema, paperclipSemanticAction("create_task")!.inputSchema]) {
|
||||
const validate = ajv.compile(schema);
|
||||
const input = { title: "Hello", idempotencyKey: "hello", assigneeUserId: "company-owner" };
|
||||
expect(validate(input), JSON.stringify(validate.errors)).toBe(true);
|
||||
expect(validate({ ...input, assigneeUserId: null })).toBe(true);
|
||||
expect(validate({ ...input, assigneeUserId: 42 })).toBe(false);
|
||||
expect(validate({ ...input, undeclaredField: true })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts project handoff receipts and preserves ordinary child task receipts", () => {
|
||||
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
|
||||
const validate = ajv.compile(createTaskAction.live.descriptor.outputSchema);
|
||||
|
||||
@@ -520,7 +520,8 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
|
||||
projectId: nullableText("Project identifier for the new task."),
|
||||
initialPlan: nullableText("Remaining execution steps to persist as the task plan. Exclude completed planning, approval, and handoff steps; cite the source plan revision and approval. A copied plan is not a new approval gate."),
|
||||
description: nullableText("Child task description."),
|
||||
assigneeActorId: nullableText("Optional actor assignee.", 200),
|
||||
assigneeActorId: nullableText("Optional actor assignee. Mutually exclusive with assigneeUserId.", 200),
|
||||
assigneeUserId: nullableText("Company person ID from list_people. Mutually exclusive with assigneeActorId.", 200),
|
||||
status: { enum: ["backlog", "todo"], description: "Initial status. Use backlog to save work without execution. Defaults to todo (blocked when dependencies are unresolved)." },
|
||||
priority: { enum: ["critical", "high", "medium", "low"] },
|
||||
blockedByTaskIds: stringArray("Initial blocker task identifiers."),
|
||||
|
||||
@@ -26,6 +26,31 @@ describe("Runner workspace bridge", () => {
|
||||
expect((await readWorkspaceUploadFile(directory, "hello.txt")).toString()).toBe("updated");
|
||||
await expect(executeWorkspaceTool(directory, "workspace_read", { path: "outside/private.txt" }, authorize)).rejects.toThrow("symlink");
|
||||
});
|
||||
it.each(["workspace", "skill"])("preserves Unicode characters across %s text pages", async mode => {
|
||||
const text = "a".repeat(5999) + "🌍" + "z".repeat(6000) + "🚀";
|
||||
const directory = await root();
|
||||
await writeFile(join(directory, "unicode.txt"), text);
|
||||
const bundle = await materializeAsset([{ path: "SKILL.md", content: Buffer.from(text), mode: 0o444 }]);
|
||||
const context = { skills: [{ key: "unicode", runtimeName: "unicode", versionId: "version-1", bundle }] } as any;
|
||||
const read = async (offset: number) => (mode === "workspace"
|
||||
? executeWorkspaceTool(directory, "workspace_read", { path: "unicode.txt", offset }, authorize)
|
||||
: readAssignedSkill(context, { skill: "unicode", offset })) as Promise<{ text: string; nextOffset: number | null; sha256: string; byteSize: number }>;
|
||||
const first = await read(0);
|
||||
expect(first.nextOffset).toBe(5999);
|
||||
expect(first.text).toBe("a".repeat(5999));
|
||||
let combined = first.text, next = first.nextOffset;
|
||||
while (next !== null) {
|
||||
const page = await read(next);
|
||||
expect(page.sha256).toBe(first.sha256);
|
||||
expect(page.byteSize).toBe(Buffer.byteLength(text));
|
||||
expect(page.text).not.toMatch(/^[\uDC00-\uDFFF]|[\uD800-\uDBFF]$/u);
|
||||
combined += page.text;
|
||||
next = page.nextOffset;
|
||||
}
|
||||
expect(combined).toBe(text);
|
||||
await expect(read(6000)).rejects.toThrow("offset_splits_character");
|
||||
});
|
||||
|
||||
it("serializes overlapping writes so only one observed hash can commit", async () => {
|
||||
const directory = await root();
|
||||
const initial = await executeWorkspaceTool(directory, "workspace_write", { path: "shared.txt", text: "initial", expectedSha256: null }, authorize) as any;
|
||||
|
||||
@@ -67,10 +67,17 @@ async function readFilePage(file: string, offset: number) {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error("runner_bridge_file_size_limit");
|
||||
const bytes = await handle.readFile();
|
||||
// Character pagination avoids splitting UTF-8 sequences; size and hash remain byte-based.
|
||||
// Offsets remain UTF-16 units, but page boundaries preserve complete code points.
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
const page = text.slice(offset, offset + 6000);
|
||||
return { text: page, sha256: digest(bytes), byteSize: bytes.length, nextOffset: offset + page.length < text.length ? offset + page.length : null };
|
||||
const splitsCharacter = (at: number) => {
|
||||
const before = text.charCodeAt(at - 1), after = text.charCodeAt(at);
|
||||
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
|
||||
};
|
||||
if (splitsCharacter(offset)) throw new Error("runner_bridge_offset_splits_character");
|
||||
let end = Math.min(offset + 6000, text.length);
|
||||
if (splitsCharacter(end)) end--;
|
||||
const page = text.slice(offset, end);
|
||||
return { text: page, sha256: digest(bytes), byteSize: bytes.length, nextOffset: end < text.length ? end : null };
|
||||
} finally { await handle.close(); }
|
||||
}
|
||||
export async function readAssignedSkill(context: NativeRuntimeContextSnapshot, raw: unknown) {
|
||||
|
||||
Reference in new issue
Block a user