From 37c09c82de2dab5d4b47538c0103cf2b9a75aec6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 08:38:20 -0500 Subject: [PATCH] Bind Runner human interactions --- .../paperclip-runner-tool-authority.test.ts | 30 ++++++++- .../paperclip-runner-tool-authority.ts | 61 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 526b75d93f0..3325c4ff822 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; -import { agents, companies, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db"; +import { agents, companies, createDb, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; @@ -53,9 +53,10 @@ describe("PaperclipRunnerToolAuthority", () => { it("advertises only real bindings and reads the bound task", async () => { const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); - expect(authority.definitions()).toHaveLength(12); + expect(authority.definitions()).toHaveLength(13); expect(authority.definitions().map((tool) => tool.name)).toEqual(expect.arrayContaining([ "get_task_context", "get_task_history", "search_tasks", "report_progress", + "request_human_input", "list_documents", "read_document", "list_document_revisions", "list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context", ])); @@ -83,6 +84,31 @@ describe("PaperclipRunnerToolAuthority", () => { })).rejects.toThrow("paperclip_runner_tool_idempotency_conflict"); }); + it("creates checkbox interactions through the real interaction service", async () => { + const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); + await expect(authority.execute({ + tool: "request_human_input", + callId: "ask-checkbox", + arguments: { + idempotencyKey: "favorite-animals", + interactionKind: "checkbox", + title: "Favorite zoo animals", + prompt: "Which zoo animals are your favorites?", + continuationPolicy: "wake_assignee", + payload: { + options: [ + { id: "giraffes", label: "Giraffes" }, + { id: "lions", label: "Lions" }, + ], + }, + }, + })).resolves.toMatchObject({ + interaction: { kind: "request_checkbox_confirmation", status: "pending" }, + }); + expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))) + .toHaveLength(1); + }); + it("fails closed once the run is no longer active", async () => { await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId)); const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); 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 e7b9e0b8e81..c94574c426b 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -6,9 +6,12 @@ import { agentService } from "../agents.js"; import { approvalService } from "../approvals.js"; import { documentService } from "../documents.js"; import { issueService } from "../issues.js"; +import { issueThreadInteractionService } from "../issue-thread-interactions.js"; +import { persistActivity, publishActivity } from "../activity-log.js"; const IMPLEMENTED_OPERATIONS = new Set([ "get_task_context", "get_task_history", "search_tasks", "report_progress", + "request_human_input", "list_documents", "read_document", "list_document_revisions", "list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context", ]); @@ -110,6 +113,7 @@ export class PaperclipRunnerToolAuthority { return { approval, tasks: tasks.map((row) => row.issue) }; } case "report_progress": return this.#reportProgress(call.callId, call.arguments); + case "request_human_input": return this.#requestHumanInput(input, context.issue); default: throw new Error("paperclip_runner_tool_not_bound"); } } @@ -178,6 +182,63 @@ export class PaperclipRunnerToolAuthority { return result; }); } + + async #requestHumanInput(input: Record, issue: typeof issues.$inferSelect): Promise { + const interactionKind = requiredString(input.interactionKind); + const kind = ({ + confirmation: "request_confirmation", + checkbox: "request_checkbox_confirmation", + questions: "ask_user_questions", + suggest_tasks: "suggest_tasks", + item_verdicts: "request_item_verdicts", + } as const)[interactionKind as "confirmation"]; + if (!kind) throw new Error("paperclip_runner_interaction_kind_invalid"); + const suppliedPayload = record(input.payload); + if (input.targetRevisionId && suppliedPayload.target === undefined) { + throw new Error("paperclip_runner_interaction_target_incomplete"); + } + const prompt = requiredString(input.prompt); + const interaction = await issueThreadInteractionService(this.db).create(issue, { + kind, + idempotencyKey: requiredString(input.idempotencyKey), + sourceRunId: this.binding.runId, + title: requiredString(input.title), + summary: prompt, + continuationPolicy: requiredString(input.continuationPolicy), + payload: { + ...suppliedPayload, + version: 1, + prompt, + ...(kind === "request_confirmation" ? { + detailsMarkdown: suppliedPayload.detailsMarkdown ?? "", + acceptLabel: suppliedPayload.acceptLabel ?? "Confirm", + rejectLabel: suppliedPayload.rejectLabel ?? "Request changes", + rejectRequiresReason: suppliedPayload.rejectRequiresReason ?? false, + supersedeOnUserComment: suppliedPayload.supersedeOnUserComment ?? true, + } : {}), + }, + } as never, { agentId: this.binding.agentId, userId: null }); + const activity = await persistActivity(this.db, { + companyId: this.binding.companyId, + actorType: "agent", + actorId: this.binding.agentId, + agentId: this.binding.agentId, + runId: this.binding.runId, + issueId: this.binding.issueId, + action: "issue.thread_interaction_created", + entityType: "issue", + entityId: this.binding.issueId, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + continuationPolicy: interaction.continuationPolicy, + source: "paperclip_runner_protocol", + }, + }); + publishActivity(activity.publication); + return { interaction, disposition: "applied" }; + } } function requiredString(value: unknown): string {