diff --git a/packages/paperclip-runner/generated/semantic-action-catalog.json b/packages/paperclip-runner/generated/semantic-action-catalog.json index dce0f1167e..822984b3d7 100644 --- a/packages/paperclip-runner/generated/semantic-action-catalog.json +++ b/packages/paperclip-runner/generated/semantic-action-catalog.json @@ -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", diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts index df7307a078..bcdfbb7ff1 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts @@ -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); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts index 6f4480a338..4c77c08fec 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts @@ -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."), diff --git a/server/src/services/native-runtime/runner-bridge-tools.test.ts b/server/src/services/native-runtime/runner-bridge-tools.test.ts index c1460cfb0a..266adca486 100644 --- a/server/src/services/native-runtime/runner-bridge-tools.test.ts +++ b/server/src/services/native-runtime/runner-bridge-tools.test.ts @@ -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; diff --git a/server/src/services/native-runtime/runner-bridge-tools.ts b/server/src/services/native-runtime/runner-bridge-tools.ts index 7b4ce81861..21858e0bae 100644 --- a/server/src/services/native-runtime/runner-bridge-tools.ts +++ b/server/src/services/native-runtime/runner-bridge-tools.ts @@ -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) {