diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 3b57ae63768..2db9f54c878 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -291,11 +291,11 @@ const TASK_API_PATHS: Readonly> = { write_task_document: ["PUT", "/api/issues/{id}/documents/{key}"], }; -function taskBridgeApiRequest(tool: string, input: Record): RunnerApiCall { +export function taskBridgeApiRequest(tool: string, input: Record): RunnerApiCall { const [method, path] = TASK_API_PATHS[tool]!; const operation = runnerApiCatalog().find(operation => operation.method === method && operation.path === path); if (!operation) throw new Error("runner_task_api_operation_unavailable"); - const body = tool === "comment_on_task" ? { body: input.body } : tool === "write_task_document" + const body = tool === "comment_on_task" ? { body: input.body, ...(input.resume === true ? { resume: true } : {}) } : tool === "write_task_document" ? { title: input.title, format: "markdown", body: input.body, baseRevisionId: input.baseRevisionId } : undefined; return { operationId: operation.operationId, pathParams: { id: input.taskId, ...(input.key ? { key: input.key } : {}) }, ...(body ? { body } : {}) }; } 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 d06839777ac..b46cf65e215 100644 --- a/server/src/services/native-runtime/runner-bridge-tools.test.ts +++ b/server/src/services/native-runtime/runner-bridge-tools.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { materializeAsset } from "./runtime-context.js"; +import { taskBridgeApiRequest } from "./paperclip-runner-tool-authority.js"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; @@ -86,6 +87,26 @@ describe("Runner workspace bridge", () => { expect(await readFile(join(directory, "result.txt"), "utf8")).toBe("hello"); }); + it("tells agents that a comment on a running task waits for the assignee's next turn", () => { + // A chat lead routing a requirement change to busy work otherwise reads "without interrupting" + // as "never delivered" and reaches for the assignee's workspace or a duplicate task instead. + const comment = runnerBridgeDefinitions({ workspace: false, skills: false, api: true, mode: "standard" }).find(tool => tool.name === "comment_on_task"); + expect(comment?.description).toContain("existing task's assignee a new or changed requirement"); + expect(comment?.description).toContain("rather than creating a duplicate task or editing that task's workspace files"); + expect(comment?.description).toContain("delivers it at the start of the assignee's next turn on that task"); + expect(comment?.description).toContain("set resume: true for such a requirement"); + expect(comment?.description).toContain("delivery still happens if the assignee completes the task first"); + expect(comment?.description).toContain("not as already applied"); + expect((comment?.inputSchema as { properties?: Record }).properties?.resume).toEqual({ type: "boolean" }); + }); + it("forwards a comment's explicit resume intent to the comment API and omits it otherwise", () => { + const taskId = "8ba8b5a2-066a-4bb2-b591-e396942ecdf5"; + expect(taskBridgeApiRequest("comment_on_task", { taskId, body: "Set the headline to Launch sooner.", resume: true }).body) + .toEqual({ body: "Set the headline to Launch sooner.", resume: true }); + expect(taskBridgeApiRequest("comment_on_task", { taskId, body: "FYI only." }).body).toEqual({ body: "FYI only." }); + expect(taskBridgeApiRequest("comment_on_task", { taskId, body: "FYI only.", resume: false }).body).toEqual({ body: "FYI only." }); + }); + it.skipIf(process.platform === "linux")("does not expose or execute commands without descendant containment", async () => { const directory = await root(); expect(workspaceCommandSandboxAvailable()).toBe(false); diff --git a/server/src/services/native-runtime/runner-bridge-tools.ts b/server/src/services/native-runtime/runner-bridge-tools.ts index fab6e460af7..f66327e559f 100644 --- a/server/src/services/native-runtime/runner-bridge-tools.ts +++ b/server/src/services/native-runtime/runner-bridge-tools.ts @@ -14,7 +14,7 @@ const specs = [ ["get_identity", "Read your agent identity, responsible person, permissions and currently available tools.", z.object({}).strict(), "read"], ["list_people", "List active company people and their IDs for human assignment; roles identify the owner.", z.object({ after: z.string().optional(), limit: z.number().int().min(1).max(100).default(50) }).strict(), "read"], ["get_task", "Read another task through normal task permissions.", z.object({ taskId: z.uuid() }).strict(), "read"], - ["comment_on_task", "Post an attributed comment on an authorized task, without reopening or interrupting it.", z.object({ taskId: z.uuid(), body: z.string().trim().min(1).max(20000) }).strict(), "mutation"], + ["comment_on_task", "Post an attributed comment on an authorized task, without reopening or interrupting it. Use it to give an existing task's assignee a new or changed requirement, rather than creating a duplicate task or editing that task's workspace files, and set resume: true for such a requirement. If the assignee is running on that task now, the comment does not reach the run in progress: Paperclip saves it in the task's queue and delivers it at the start of the assignee's next turn on that task. With resume: true that delivery still happens if the assignee completes the task first; without it, a comment on finished work is only a note. Report a queued requirement as saved for the next turn, not as already applied.", z.object({ taskId: z.uuid(), body: z.string().trim().min(1).max(20000), resume: z.boolean().optional() }).strict(), "mutation"], ["list_task_documents", "List documents on an authorized task.", z.object({ taskId: z.uuid() }).strict(), "read"], ["read_task_document", "Read a document on an authorized task.", z.object({ taskId: z.uuid(), key: documentKey }).strict(), "read"], ["write_task_document", "Write a document on an authorized task using its current revision. Permissions and document locks apply.", z.object({ taskId: z.uuid(), key: documentKey, title: z.string().min(1).max(200), body: z.string().max(20000), baseRevisionId: z.uuid().nullable() }).strict(), "mutation"],