From 10560e64be646b9e1f63cbd0ab75e4a246d34bd2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 25 Aug 2026 16:42:53 -0500 Subject: [PATCH] feat(runner): integrate native runs into task threads --- packages/shared/src/index.ts | 4 + .../src/issue-thread-interactions.test.ts | 39 ++ packages/shared/src/types/index.ts | 3 + packages/shared/src/types/issue.ts | 42 +++ packages/shared/src/validators/index.ts | 1 + packages/shared/src/validators/issue.ts | 103 +++++- server/src/routes/agents.ts | 2 + server/src/routes/issues.ts | 27 +- server/src/services/activity.ts | 1 + server/src/services/heartbeat.ts | 1 + .../native-question-bridge.test.ts | 319 ++++++++++++++++ .../native-runtime/native-question-bridge.ts | 346 ++++++++++++++++++ .../native-runtime/runner-prp-coordinator.ts | 47 ++- server/src/vendor/paperclip-runner/index.ts | 5 + ui/src/api/activity.ts | 1 + ui/src/api/heartbeats.ts | 2 + .../IssueThreadInteractionCard.test.tsx | 26 ++ .../components/IssueThreadInteractionCard.tsx | 2 +- ui/src/components/TaskChatThread.test.tsx | 52 ++- ui/src/components/TaskChatThread.tsx | 22 +- .../task-chat/TaskChatUsageReadout.tsx | 25 +- .../task-chat/transcript-adapter.test.ts | 45 +++ .../task-chat/transcript-adapter.ts | 22 +- .../transcript/native-run-events.test.ts | 136 +++++++ .../transcript/native-run-events.ts | 161 ++++++++ .../transcript/useLiveRunTranscripts.ts | 1 + .../transcript/useNativeRunTranscripts.ts | 100 +++++ ui/src/lib/issue-chat-messages.ts | 1 + 28 files changed, 1501 insertions(+), 35 deletions(-) create mode 100644 server/src/services/native-runtime/native-question-bridge.test.ts create mode 100644 server/src/services/native-runtime/native-question-bridge.ts create mode 100644 ui/src/components/transcript/native-run-events.test.ts create mode 100644 ui/src/components/transcript/native-run-events.ts create mode 100644 ui/src/components/transcript/useNativeRunTranscripts.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index eba88a35d52..d0fb72010fe 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1067,6 +1067,9 @@ export type { SuggestTasksResult, AskUserQuestionsQuestionOption, AskUserQuestionsQuestion, + PaperclipQuestionSetOption, + PaperclipQuestionSetQuestion, + PaperclipQuestionSetPayload, AskUserQuestionsPayload, AskUserQuestionsAnswer, AskUserQuestionsResult, @@ -1826,6 +1829,7 @@ export { suggestTasksResultSchema, askUserQuestionsQuestionOptionSchema, askUserQuestionsQuestionSchema, + paperclipQuestionSetPayloadSchema, askUserQuestionsPayloadSchema, askUserQuestionsAnswerSchema, askUserQuestionsResultSchema, diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index 37ce228ba0b..fed1aa714c6 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -9,6 +9,7 @@ import { acceptIssueThreadInteractionSchema, askUserQuestionsResultSchema, createIssueThreadInteractionSchema, + paperclipQuestionSetPayloadSchema, requestConfirmationPayloadSchema, requestConfirmationResultSchema, requestItemVerdictsResultSchema, @@ -258,6 +259,44 @@ describe("issue thread interaction schemas", () => { }); }); + it("retains canonical runner question sets without narrowing their public bounds", () => { + const questionSet = { + schema: "paperclip.question_set.v1" as const, + title: "Runner input", + questions: [{ + id: "deployment-color", + prompt: "Which deployment color should the runner use?", + required: true, + answerMode: "single_select" as const, + options: [{ id: "blue", label: "Blue" }], + }], + }; + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "ask_user_questions", + continuationPolicy: "none", + resolverPolicy: "human_only", + payload: { + version: 1, + questions: [{ + id: "deployment-color", + prompt: "Which deployment color should the runner use?", + selectionMode: "single", + allowOther: false, + options: [{ id: "blue", label: "Blue" }], + }], + questionSet, + }, + }); + expect(parsed.kind).toBe("ask_user_questions"); + if (parsed.kind !== "ask_user_questions") return; + expect(parsed.payload.questionSet).toEqual(questionSet); + + expect(() => paperclipQuestionSetPayloadSchema.parse({ + ...questionSet, + questions: [{ ...questionSet.questions[0], answerMode: "text", options: questionSet.questions[0].options }], + })).toThrow("text questions cannot define options"); + }); + it("rejects unsafe request_confirmation target hrefs", () => { const base = { kind: "request_confirmation", diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0cfac72d34d..2d0a08691cb 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -692,6 +692,9 @@ export type { SuggestTasksResult, AskUserQuestionsQuestionOption, AskUserQuestionsQuestion, + PaperclipQuestionSetOption, + PaperclipQuestionSetQuestion, + PaperclipQuestionSetPayload, AskUserQuestionsPayload, AskUserQuestionsAnswer, AskUserQuestionsResult, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 151dd32f10a..1e42e5deeda 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1109,15 +1109,57 @@ export interface AskUserQuestionsQuestion { helpText?: string | null; selectionMode: "single" | "multi"; required?: boolean; + /** False suppresses the legacy free-form fallback for closed select sets. */ + allowOther?: boolean; options: AskUserQuestionsQuestionOption[]; } +/** Provider-neutral question presentation retained for a native runner request. */ +export interface PaperclipQuestionSetOption { + id: string; + label: string; + description?: string; +} + +export interface PaperclipQuestionSetQuestion { + id: string; + header?: string; + prompt: string; + helpText?: string; + required: boolean; + answerMode: "single_select" | "multi_select" | "text"; + options?: PaperclipQuestionSetOption[]; + customAnswer?: { + enabled: true; + label?: string; + placeholder?: string; + }; + textValidation?: { + minLength?: number; + maxLength?: number; + pattern?: string; + inputType?: "text" | "number" | "integer"; + minimum?: number; + maximum?: number; + }; +} + +export interface PaperclipQuestionSetPayload { + schema: "paperclip.question_set.v1"; + title?: string; + description?: string; + submitLabel?: string; + questions: PaperclipQuestionSetQuestion[]; +} + export interface AskUserQuestionsPayload { version: 1; title?: string | null; submitLabel?: string | null; supersedeOnUserComment?: boolean; questions: AskUserQuestionsQuestion[]; + /** Exact canonical contract used to validate a native runner response. */ + questionSet?: PaperclipQuestionSetPayload; } export interface AskUserQuestionsAnswer { diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 08dbb03be35..4321abdb2b3 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -446,6 +446,7 @@ export { suggestTasksResultSchema, askUserQuestionsQuestionOptionSchema, askUserQuestionsQuestionSchema, + paperclipQuestionSetPayloadSchema, askUserQuestionsPayloadSchema, askUserQuestionsAnswerSchema, askUserQuestionsResultSchema, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 01e688cf0c2..e4a841961d5 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -826,9 +826,9 @@ export const suggestTasksResultSchema = z.object({ }); export const askUserQuestionsQuestionOptionSchema = z.object({ - id: z.string().trim().min(1).max(120), - label: z.string().trim().min(1).max(120), - description: z.string().trim().max(500).nullable().optional(), + id: z.string().trim().min(1).max(160), + label: z.string().trim().min(1).max(1000), + description: z.string().trim().max(4000).nullable().optional(), freeText: z .boolean() .optional() @@ -838,12 +838,90 @@ export const askUserQuestionsQuestionOptionSchema = z.object({ }); export const askUserQuestionsQuestionSchema = z.object({ - id: z.string().trim().min(1).max(120), - prompt: z.string().trim().min(1).max(500), - helpText: z.string().trim().max(1000).nullable().optional(), + id: z.string().trim().min(1).max(160), + prompt: z.string().trim().min(1).max(4000), + helpText: z.string().trim().max(4000).nullable().optional(), selectionMode: z.enum(["single", "multi"]), required: z.boolean().optional(), - options: z.array(askUserQuestionsQuestionOptionSchema).min(1).max(10), + allowOther: z.boolean().optional(), + options: z.array(askUserQuestionsQuestionOptionSchema).min(1).max(129), +}); + +const paperclipQuestionOptionSchema = z.object({ + id: z.string().min(1).max(160), + label: z.string().min(1).max(1000), + description: z.string().max(4000).optional(), +}); + +const paperclipQuestionSchema = z.object({ + id: z.string().min(1).max(160), + header: z.string().max(1000).optional(), + prompt: z.string().min(1).max(4000), + helpText: z.string().max(4000).optional(), + required: z.boolean(), + answerMode: z.enum(["single_select", "multi_select", "text"]), + options: z.array(paperclipQuestionOptionSchema).max(128).optional(), + customAnswer: z.object({ + enabled: z.literal(true), + label: z.string().max(1000).optional(), + placeholder: z.string().max(1000).optional(), + }).optional(), + textValidation: z.object({ + minLength: z.number().int().min(0).max(100000).optional(), + maxLength: z.number().int().min(0).max(100000).optional(), + pattern: z.string().max(1000).optional(), + inputType: z.enum(["text", "number", "integer"]).optional(), + minimum: z.number().finite().optional(), + maximum: z.number().finite().optional(), + }).optional(), +}).superRefine((value, ctx) => { + if (value.answerMode === "text" && value.options && value.options.length > 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "text questions cannot define options", path: ["options"] }); + } + if (value.answerMode !== "text" && (!value.options || value.options.length === 0)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "select questions require options", path: ["options"] }); + } + if (value.answerMode === "text" && value.customAnswer) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "text questions cannot define customAnswer", path: ["customAnswer"] }); + } + if ( + value.textValidation?.minLength !== undefined + && value.textValidation.maxLength !== undefined + && value.textValidation.minLength > value.textValidation.maxLength + ) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "minLength cannot exceed maxLength", path: ["textValidation"] }); + } + if ( + value.textValidation?.minimum !== undefined + && value.textValidation.maximum !== undefined + && value.textValidation.minimum > value.textValidation.maximum + ) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "minimum cannot exceed maximum", path: ["textValidation"] }); + } + if (value.textValidation?.pattern !== undefined) { + try { + new RegExp(value.textValidation.pattern); + } catch { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "pattern must be a valid regular expression", path: ["textValidation", "pattern"] }); + } + } + const optionIds = value.options?.map((option) => option.id) ?? []; + if (new Set(optionIds).size !== optionIds.length) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "option ids must be unique", path: ["options"] }); + } +}); + +export const paperclipQuestionSetPayloadSchema = z.object({ + schema: z.literal("paperclip.question_set.v1"), + title: z.string().max(1000).optional(), + description: z.string().max(4000).optional(), + submitLabel: z.string().max(200).optional(), + questions: z.array(paperclipQuestionSchema).min(1).max(64), +}).superRefine((value, ctx) => { + const questionIds = value.questions.map((question) => question.id); + if (new Set(questionIds).size !== questionIds.length) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "question ids must be unique", path: ["questions"] }); + } }); export const askUserQuestionsPayloadSchema = z.object({ @@ -851,7 +929,8 @@ export const askUserQuestionsPayloadSchema = z.object({ title: z.string().trim().max(240).nullable().optional(), submitLabel: z.string().trim().max(120).nullable().optional(), supersedeOnUserComment: z.boolean().optional(), - questions: z.array(askUserQuestionsQuestionSchema).min(1).max(10), + questions: z.array(askUserQuestionsQuestionSchema).min(1).max(64), + questionSet: paperclipQuestionSetPayloadSchema.optional(), }).superRefine((value, ctx) => { const seenQuestionIds = new Set(); for (const [questionIndex, question] of value.questions.entries()) { @@ -890,16 +969,16 @@ export const askUserQuestionsPayloadSchema = z.object({ }); export const askUserQuestionsAnswerSchema = z.object({ - questionId: z.string().trim().min(1).max(120), - optionIds: z.array(z.string().trim().min(1).max(120)).max(20), - otherText: multilineTextSchema.pipe(z.string().trim().max(4000)).nullable().optional(), + questionId: z.string().trim().min(1).max(160), + optionIds: z.array(z.string().trim().min(1).max(160)).max(129), + otherText: multilineTextSchema.pipe(z.string().trim().max(100000)).nullable().optional(), }); export const askUserQuestionsResultSchema = z.object({ version: z.literal(1), outcome: z.enum(["withdrawn", "issue_closed", "addressee_deleted"]).optional(), reason: z.string().trim().max(4000).nullable().optional(), - answers: z.array(askUserQuestionsAnswerSchema).max(20), + answers: z.array(askUserQuestionsAnswerSchema).max(64), cancelled: z.literal(true).optional(), cancellationReason: z.string().trim().max(4000).nullable().optional(), expirationReason: z.enum(["superseded_by_comment", "superseded_by_newer_interaction"]).optional(), diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 94ab86d2dd4..005a2b18af5 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -5113,6 +5113,7 @@ export function agentRoutes( const columns = { id: heartbeatRuns.id, + runtimeMode: heartbeatRuns.runtimeMode, companyId: heartbeatRuns.companyId, status: heartbeatRuns.status, invocationSource: heartbeatRuns.invocationSource, @@ -5338,6 +5339,7 @@ export function agentRoutes( const liveRuns = await db .select({ id: heartbeatRuns.id, + runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b5e6a3adb28..f5698f53fa4 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -207,6 +207,11 @@ import { environmentService } from "../services/environments.js"; import { environmentRuntimeService } from "../services/environment-runtime.js"; import { redactSensitiveText } from "../redaction.js"; import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js"; +import { + deliverNativeQuestionResponse, + nativeQuestionRunToCancel, + validateNativeQuestionResponseInput, +} from "../services/native-runtime/native-question-bridge.js"; import { createCompanySearchRateLimiter, type CompanySearchRateLimiter, @@ -11657,9 +11662,12 @@ export function issueRoutes( interactionId, ); if (!authorizedResolution) return; - const { interactionSvc, resolutionAuthorization } = authorizedResolution; + const { interactionSvc, current, resolutionAuthorization } = authorizedResolution; const actor = getActorInfo(req); + if (current.kind === "ask_user_questions") { + validateNativeQuestionResponseInput(current, req.body); + } const interaction = await interactionSvc.answerQuestions(issue, interactionId, req.body, { agentId: actor.agentId, runId: actor.runId, @@ -11694,6 +11702,10 @@ export function issueRoutes( }, }); + if (interaction.kind === "ask_user_questions") { + await deliverNativeQuestionResponse(db, interaction); + } + await queueResolvedInteractionContinuationWakeup({ db, heartbeat, @@ -11882,6 +11894,19 @@ export function issueRoutes( }, }); + if (interaction.kind === "ask_user_questions") { + const nativeRunId = await nativeQuestionRunToCancel(db, interaction); + if (nativeRunId) { + await heartbeat.cancelRun(nativeRunId, "Cancelled while waiting for operator input", { + resultJson: { + cancelledByActorType: "user", + cancelledByUserId: req.actor.userId ?? null, + cancelledInteractionId: interaction.id, + }, + }); + } + } + await queueResolvedInteractionContinuationWakeup({ db, heartbeat, diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index a9c6f592dd0..5cc2cc625af 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -381,6 +381,7 @@ export function activityService(db: Db) { const runs = await db .select({ runId: heartbeatRuns.id, + runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status, agentId: heartbeatRuns.agentId, adapterType: agents.adapterType, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 69419dad9de..0be16cb8faa 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2496,6 +2496,7 @@ const heartbeatRunLogAccessColumns = { const heartbeatRunIssueSummaryColumns = { id: heartbeatRuns.id, + runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, diff --git a/server/src/services/native-runtime/native-question-bridge.test.ts b/server/src/services/native-runtime/native-question-bridge.test.ts new file mode 100644 index 00000000000..aa5fd3dd5ab --- /dev/null +++ b/server/src/services/native-runtime/native-question-bridge.test.ts @@ -0,0 +1,319 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq, sql } from "drizzle-orm"; + +import { + activityLog, + agents, + companies, + createDb, + heartbeatRuns, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; +import type { PrpEvent } from "@paperclipai/paperclip-runner"; + +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "../../__tests__/helpers/embedded-postgres.js"; +import { issueThreadInteractionService } from "../issue-thread-interactions.js"; +import { + deliverNativeQuestionResponse, + flushNativeQuestionResponses, + nativeQuestionBridgeInternals, + nativeQuestionRunToCancel, + projectNativeRuntimeRequest, + registerNativeQuestionCommandTarget, + validateNativeQuestionResponseInput, +} from "./native-question-bridge.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping native question bridge tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("native question bridge", () => { + let temporary: Awaited> | null = null; + let db: ReturnType; + let companyId: string; + let issueId: string; + let agentId: string; + let runId: string; + let sessionId: string; + let runnerInstanceId: string; + + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-question-"); + db = createDb(temporary.connectionString); + }, 20_000); + + afterEach(async () => { + nativeQuestionBridgeInternals.resetForTests(); + await db.execute(sql.raw(` + TRUNCATE TABLE + "activity_log", + "issue_thread_interactions", + "heartbeat_runs", + "agent_wakeup_requests", + "issues", + "agents", + "companies" + RESTART IDENTITY CASCADE + `)); + }); + + afterAll(async () => temporary?.cleanup()); + + async function seed() { + companyId = randomUUID(); + issueId = randomUUID(); + agentId = randomUUID(); + runId = randomUUID(); + sessionId = randomUUID(); + runnerInstanceId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Native questions", + issuePrefix: `NQ${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Native Codex", + adapterType: "paperclip_runner", + status: "running", + adapterConfig: { provider: "codex" }, + runtimeConfig: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Answer a native question", + status: "in_progress", + assigneeAgentId: agentId, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + runtimeMode: "native", + runtimeModeResolvedAt: new Date(), + nativeIssueId: issueId, + nativeSessionId: sessionId, + runnerInstanceId, + driverKind: "codex", + contextSnapshot: { issueId }, + }); + } + + function runtimeRequestEvent(): PrpEvent { + return { + schema: "paperclip.prp.event.v1", + sourceEventId: "runtime-question-1", + sourceSeq: 1, + sourceInstanceId: runnerInstanceId, + sourceKind: "runner", + runId, + normalizedSessionId: sessionId, + turnId: "turn-1", + itemId: "item-1", + eventType: "runtime_request.created", + schemaVersion: 1, + priority: 0, + emittedAt: "2026-08-25T18:00:00.000Z", + payload: { + request: { + schema: "paperclip.runtime_request.v2", + requestKind: "runtime", + requestId: "request-1", + type: "input", + status: "pending", + prompt: "Choose a deployment color", + input: { + schema: "paperclip.question_set.v1", + title: "Deployment", + questions: [{ + id: "color", + prompt: "Which color?", + required: true, + answerMode: "single_select", + options: [ + { id: "blue", label: "Blue" }, + { id: "green", label: "Green" }, + ], + }], + }, + }, + }, + }; + } + + function binding() { + return { + companyId, + issueId, + runId, + agentId, + normalizedSessionId: sessionId, + runnerSourceInstanceId: runnerInstanceId, + completionContractId: randomUUID(), + completionContractSha256: `sha256:${"a".repeat(64)}`, + completionContractRevision: "1", + completionContractCriterionIds: [], + }; + } + + it("materializes, validates, and durably resumes a provider-neutral question response", async () => { + await seed(); + const interaction = await projectNativeRuntimeRequest({ + db, + binding: binding(), + event: runtimeRequestEvent(), + }); + + expect(interaction).toMatchObject({ + kind: "ask_user_questions", + status: "pending", + sourceRunId: runId, + continuationPolicy: "none", + effectiveResolverPolicy: "human_only", + payload: { + questionSet: { schema: "paperclip.question_set.v1" }, + questions: [{ + id: "color", + selectionMode: "single", + allowOther: false, + options: [{ id: "blue", label: "Blue" }, { id: "green", label: "Green" }], + }], + }, + }); + expect(await db.select().from(activityLog)).toHaveLength(1); + + const answer = { answers: [{ questionId: "color", optionIds: ["blue"] }] }; + validateNativeQuestionResponseInput(interaction!, answer); + expect(() => validateNativeQuestionResponseInput(interaction!, { + answers: [{ questionId: "color", optionIds: ["red"] }], + })).toThrow(/unknown option red/); + + const answered = await issueThreadInteractionService(db).answerQuestions( + { id: issueId, companyId, status: "in_progress" }, + interaction!.id, + answer, + { userId: "operator-1" }, + ); + const queueCommand = vi.fn(() => ({ commandId: "question", controllerSeq: 1 })); + const release = registerNativeQuestionCommandTarget({ + binding: { companyId, issueId, runId, agentId }, + queueCommand, + }); + + await flushNativeQuestionResponses(db, runId); + expect(queueCommand).toHaveBeenCalledWith( + "request.resolve", + { + requestId: "request-1", + response: { + schema: "paperclip.question_response.v1", + answers: { color: { selectedOptionIds: ["blue"] } }, + }, + }, + `question_${interaction!.id}`, + ); + expect(answered.kind).toBe("ask_user_questions"); + if (answered.kind !== "ask_user_questions") throw new Error("expected question interaction"); + await expect(deliverNativeQuestionResponse(db, answered)).resolves.toBe("queued"); + await expect(nativeQuestionRunToCancel(db, answered)).resolves.toBe(runId); + release(); + }); + + it("binds projection to the persisted native run and ignores legacy delivery", async () => { + await seed(); + const mismatched = runtimeRequestEvent(); + mismatched.runId = randomUUID(); + await expect(projectNativeRuntimeRequest({ db, binding: binding(), event: mismatched })) + .rejects.toThrow("native_runtime_request_binding_mismatch"); + + const interaction = await projectNativeRuntimeRequest({ + db, + binding: binding(), + event: runtimeRequestEvent(), + }); + const answered = await issueThreadInteractionService(db).answerQuestions( + { id: issueId, companyId, status: "in_progress" }, + interaction!.id, + { answers: [{ questionId: "color", optionIds: ["green"] }] }, + { userId: "operator-1" }, + ); + await db.update(heartbeatRuns).set({ runtimeMode: "legacy" }).where(eq(heartbeatRuns.id, runId)); + expect(answered.kind).toBe("ask_user_questions"); + if (answered.kind !== "ask_user_questions") throw new Error("expected question interaction"); + await expect(deliverNativeQuestionResponse(db, answered)).resolves.toBe("not_native"); + await expect(nativeQuestionRunToCancel(db, answered)).resolves.toBeNull(); + }); + + it("does not duplicate the task card when the runner replays a request", async () => { + await seed(); + const first = await projectNativeRuntimeRequest({ db, binding: binding(), event: runtimeRequestEvent() }); + const second = await projectNativeRuntimeRequest({ db, binding: binding(), event: runtimeRequestEvent() }); + expect(second?.id).toBe(first?.id); + expect(await db.select().from(issueThreadInteractions)).toHaveLength(1); + expect(await db.select().from(activityLog)).toHaveLength(1); + }); + + it("removes the UI-only marker from a canonical custom response", async () => { + await seed(); + const event = runtimeRequestEvent(); + const request = event.payload.request as Record; + const input = request.input as Record; + input.questions = [{ + id: "color", + prompt: "Which color?", + required: true, + answerMode: "single_select", + options: [{ id: "blue", label: "Blue" }], + customAnswer: { enabled: true, label: "Another color" }, + }]; + const interaction = await projectNativeRuntimeRequest({ db, binding: binding(), event }); + const answer = { + answers: [{ + questionId: "color", + optionIds: ["paperclip_custom_answer"], + otherText: "purple", + }], + }; + validateNativeQuestionResponseInput(interaction!, answer); + const answered = await issueThreadInteractionService(db).answerQuestions( + { id: issueId, companyId, status: "in_progress" }, + interaction!.id, + answer, + { userId: "operator-1" }, + ); + expect(answered.kind).toBe("ask_user_questions"); + if (answered.kind !== "ask_user_questions") throw new Error("expected question interaction"); + + const queueCommand = vi.fn(() => ({ commandId: "question", controllerSeq: 1 })); + registerNativeQuestionCommandTarget({ + binding: { companyId, issueId, runId, agentId }, + queueCommand, + }); + await expect(deliverNativeQuestionResponse(db, answered)).resolves.toBe("queued"); + expect(queueCommand).toHaveBeenCalledWith( + "request.resolve", + { + requestId: "request-1", + response: { + schema: "paperclip.question_response.v1", + answers: { color: { selectedOptionIds: [], customText: "purple" } }, + }, + }, + `question_${interaction!.id}`, + ); + }); +}); diff --git a/server/src/services/native-runtime/native-question-bridge.ts b/server/src/services/native-runtime/native-question-bridge.ts new file mode 100644 index 00000000000..bb42f622053 --- /dev/null +++ b/server/src/services/native-runtime/native-question-bridge.ts @@ -0,0 +1,346 @@ +import { and, eq } from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; +import { heartbeatRuns, issueThreadInteractions } from "@paperclipai/db"; +import type { + AskUserQuestionsAnswer, + AskUserQuestionsInteraction, + AskUserQuestionsQuestionOption, + PaperclipQuestionSetPayload, + RespondIssueThreadInteraction, +} from "@paperclipai/shared"; + +import type { PrpEvent } from "../../vendor/paperclip-runner/index.js"; +import { + parsePaperclipQuestionResponse, + parsePaperclipQuestionSet, + type PaperclipQuestionResponse, + type PaperclipQuestionSet, +} from "../../vendor/paperclip-runner/index.js"; +import { logger } from "../../middleware/logger.js"; +import { unprocessable } from "../../errors.js"; +import { logActivity } from "../activity-log.js"; +import { issueThreadInteractionService } from "../issue-thread-interactions.js"; +import type { NativeRunStoreBinding } from "./native-run-coordinator-store.js"; + +const QUESTION_KEY_PREFIX = "paperclip-runner-question:"; +const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/; +const TEXT_ANSWER_OPTION_ID = "paperclip_text_answer"; +const CUSTOM_ANSWER_OPTION_ID = "paperclip_custom_answer"; + +type QueueCommand = ( + type: string, + payload?: Record, + commandId?: string, +) => { readonly commandId: string; readonly controllerSeq: number }; + +interface NativeQuestionCommandTarget { + binding: Pick; + queueCommand: QueueCommand; +} + +const activeTargets = new Map(); + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function requestIdForInteraction( + interaction: Pick, +): string | null { + if (!interaction.sourceRunId || !interaction.payload.questionSet) return null; + const key = interaction.idempotencyKey; + const expectedPrefix = `${QUESTION_KEY_PREFIX}${interaction.sourceRunId}:`; + if (!key?.startsWith(expectedPrefix)) return null; + const requestId = key.slice(expectedPrefix.length); + return REQUEST_ID_PATTERN.test(requestId) ? requestId : null; +} + +function uniqueSyntheticOptionId(existing: readonly string[], preferred: string): string { + const ids = new Set(existing); + if (!ids.has(preferred)) return preferred; + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const candidate = `${preferred}_${suffix}`; + if (!ids.has(candidate)) return candidate; + } + throw new Error("native_question_synthetic_option_exhausted"); +} + +function toInteractionPayload(questionSet: PaperclipQuestionSet) { + return { + version: 1 as const, + ...(questionSet.title ? { title: questionSet.title.slice(0, 240) } : {}), + ...(questionSet.submitLabel ? { submitLabel: questionSet.submitLabel.slice(0, 120) } : {}), + questions: questionSet.questions.map((question) => { + const canonicalOptions = question.options ?? []; + const options: AskUserQuestionsQuestionOption[] = canonicalOptions.map((option) => ({ + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + })); + if (question.answerMode === "text") { + options.push({ + id: uniqueSyntheticOptionId([], TEXT_ANSWER_OPTION_ID), + label: question.header ?? "Type an answer", + ...(question.textValidation?.inputType + ? { description: `Expected ${question.textValidation.inputType} input` } + : {}), + freeText: true, + }); + } else if (question.customAnswer?.enabled) { + options.push({ + id: uniqueSyntheticOptionId(canonicalOptions.map((option) => option.id), CUSTOM_ANSWER_OPTION_ID), + label: question.customAnswer.label ?? "Other", + ...(question.customAnswer.placeholder ? { description: question.customAnswer.placeholder } : {}), + freeText: true, + }); + } + return { + id: question.id, + prompt: question.prompt, + ...((question.helpText || question.header) + ? { helpText: question.helpText ?? question.header } + : {}), + selectionMode: question.answerMode === "multi_select" ? "multi" as const : "single" as const, + required: question.required, + allowOther: question.answerMode === "text" || question.customAnswer?.enabled === true, + options, + }; + }), + questionSet: questionSet as PaperclipQuestionSetPayload, + }; +} + +function canonicalResponse( + questionSet: PaperclipQuestionSetPayload, + answers: readonly AskUserQuestionsAnswer[], +): PaperclipQuestionResponse { + const answerByQuestionId = new Map(answers.map((answer) => [answer.questionId, answer])); + const response: PaperclipQuestionResponse = { + schema: "paperclip.question_response.v1", + answers: {}, + }; + for (const question of questionSet.questions) { + const answer = answerByQuestionId.get(question.id); + if (!answer) continue; + if (question.answerMode === "text") { + response.answers[question.id] = { + ...(answer.otherText !== undefined && answer.otherText !== null + ? { text: answer.otherText } + : {}), + }; + } else { + const customOptionId = question.customAnswer?.enabled + ? uniqueSyntheticOptionId( + (question.options ?? []).map((option) => option.id), + CUSTOM_ANSWER_OPTION_ID, + ) + : null; + response.answers[question.id] = { + selectedOptionIds: answer.optionIds.filter((optionId) => optionId !== customOptionId), + ...(answer.otherText !== undefined && answer.otherText !== null + ? { customText: answer.otherText } + : {}), + }; + } + } + return parsePaperclipQuestionResponse(questionSet, response); +} + +async function authorizedNativeRun( + db: Db, + interaction: Pick, +) { + const requestId = requestIdForInteraction(interaction); + if (!requestId || !interaction.sourceRunId) return null; + const run = await db.select({ + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + issueId: heartbeatRuns.nativeIssueId, + agentId: heartbeatRuns.agentId, + runtimeMode: heartbeatRuns.runtimeMode, + status: heartbeatRuns.status, + }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.id, interaction.sourceRunId), + eq(heartbeatRuns.companyId, interaction.companyId), + eq(heartbeatRuns.nativeIssueId, interaction.issueId), + eq(heartbeatRuns.runtimeMode, "native"), + )).limit(1).then((rows) => rows[0] ?? null); + return run ? { ...run, requestId } : null; +} + +/** Materialize a canonical runtime input request as the existing task-thread card. */ +export async function projectNativeRuntimeRequest(input: { + db: Db; + binding: NativeRunStoreBinding; + event: PrpEvent; +}): Promise { + if (input.event.eventType !== "runtime_request.created") return null; + if ( + input.event.runId !== input.binding.runId + || input.event.normalizedSessionId !== input.binding.normalizedSessionId + || input.event.sourceInstanceId !== input.binding.runnerSourceInstanceId + ) { + throw new Error("native_runtime_request_binding_mismatch"); + } + const request = record(record(input.event.payload)?.request); + if ( + !request + || request.schema !== "paperclip.runtime_request.v2" + || request.requestKind !== "runtime" + || request.type !== "input" + || request.status !== "pending" + || typeof request.requestId !== "string" + || !REQUEST_ID_PATTERN.test(request.requestId) + ) { + throw new Error("native_runtime_request_invalid"); + } + const questionSet = parsePaperclipQuestionSet(request.input); + if (questionSet.questions.some((question) => question.textValidation?.pattern !== undefined)) { + // JavaScript regular expressions have no execution budget. Provider-authored + // patterns therefore stay fail-closed until the runner contract supplies a + // bounded regex dialect rather than exposing the server to catastrophic backtracking. + throw new Error("native_runtime_question_pattern_unsupported"); + } + const idempotencyKey = `${QUESTION_KEY_PREFIX}${input.binding.runId}:${request.requestId}`; + const existing = await input.db.select({ id: issueThreadInteractions.id }) + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, input.binding.companyId), + eq(issueThreadInteractions.issueId, input.binding.issueId), + eq(issueThreadInteractions.idempotencyKey, idempotencyKey), + )) + .limit(1) + .then((rows) => rows[0] ?? null); + const interaction = await issueThreadInteractionService(input.db).create( + { id: input.binding.issueId, companyId: input.binding.companyId }, + { + kind: "ask_user_questions", + idempotencyKey, + sourceRunId: input.binding.runId, + resolverPolicy: "human_only", + continuationPolicy: "none", + ...(questionSet.title ? { title: questionSet.title.slice(0, 240) } : {}), + ...(typeof request.prompt === "string" ? { summary: request.prompt.slice(0, 1000) } : {}), + payload: toInteractionPayload(questionSet), + }, + { agentId: input.binding.agentId, runId: input.binding.runId }, + ) as AskUserQuestionsInteraction; + if (!existing) { + await logActivity(input.db, { + companyId: input.binding.companyId, + actorType: "agent", + actorId: input.binding.agentId, + agentId: input.binding.agentId, + runId: input.binding.runId, + action: "issue.thread_interaction_created", + entityType: "issue", + entityId: input.binding.issueId, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + runtimeMode: "native", + }, + }); + } + if (interaction.status === "answered") { + await deliverNativeQuestionResponse(input.db, interaction); + } + return interaction; +} + +/** Validate untrusted board input before the existing interaction service persists it. */ +export function validateNativeQuestionResponseInput( + interaction: AskUserQuestionsInteraction, + input: RespondIssueThreadInteraction, +): void { + if (!requestIdForInteraction(interaction) || !interaction.payload.questionSet) return; + try { + canonicalResponse(interaction.payload.questionSet, input.answers); + } catch (error) { + throw unprocessable( + error instanceof Error ? error.message : "Invalid native question response", + { code: "invalid_question_response" }, + ); + } +} + +/** Queue an answered interaction into the active durable PRP command stream. */ +export async function deliverNativeQuestionResponse( + db: Db, + interaction: AskUserQuestionsInteraction, +): Promise<"not_native" | "pending" | "queued"> { + if (interaction.status !== "answered" || !interaction.result || !interaction.payload.questionSet) { + return "not_native"; + } + const run = await authorizedNativeRun(db, interaction); + if (!run) return "not_native"; + const response = canonicalResponse(interaction.payload.questionSet, interaction.result.answers); + const target = activeTargets.get(run.id); + if ( + !target + || target.binding.companyId !== run.companyId + || target.binding.issueId !== run.issueId + || target.binding.agentId !== run.agentId + ) { + return "pending"; + } + try { + target.queueCommand( + "request.resolve", + { requestId: run.requestId, response: response as unknown as Record }, + `question_${interaction.id}`, + ); + return "queued"; + } catch (error) { + logger.warn( + { err: error, runId: run.id, interactionId: interaction.id }, + "native question response remains durable for session recovery", + ); + return "pending"; + } +} + +export async function flushNativeQuestionResponses( + db: Db, + runId: string, +): Promise { + const target = activeTargets.get(runId); + if (!target) return; + const interactions = await issueThreadInteractionService(db).listForIssue(target.binding.issueId); + for (const interaction of interactions) { + if ( + interaction.kind === "ask_user_questions" + && interaction.sourceRunId === runId + && interaction.status === "answered" + ) { + await deliverNativeQuestionResponse(db, interaction); + } + } +} + +export function registerNativeQuestionCommandTarget(target: NativeQuestionCommandTarget): () => void { + const existing = activeTargets.get(target.binding.runId); + if (existing) throw new Error("native_question_command_target_conflict"); + activeTargets.set(target.binding.runId, target); + return () => { + if (activeTargets.get(target.binding.runId) === target) { + activeTargets.delete(target.binding.runId); + } + }; +} + +export async function nativeQuestionRunToCancel( + db: Db, + interaction: AskUserQuestionsInteraction, +): Promise { + const run = await authorizedNativeRun(db, interaction); + return run && ["queued", "running"].includes(run.status) ? run.id : null; +} + +export const nativeQuestionBridgeInternals = { + resetForTests: () => activeTargets.clear(), +}; diff --git a/server/src/services/native-runtime/runner-prp-coordinator.ts b/server/src/services/native-runtime/runner-prp-coordinator.ts index 9d9d9f2f91e..067e77903fc 100644 --- a/server/src/services/native-runtime/runner-prp-coordinator.ts +++ b/server/src/services/native-runtime/runner-prp-coordinator.ts @@ -13,6 +13,11 @@ import { import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js"; import { NativeRunCoordinatorStore } from "./native-run-coordinator-store.js"; +import { + flushNativeQuestionResponses, + projectNativeRuntimeRequest, + registerNativeQuestionCommandTarget, +} from "./native-question-bridge.js"; import { PaperclipRunnerSemanticAuthority } from "./runner-semantic-authority.js"; const UUID_PATTERN = @@ -214,7 +219,7 @@ export function runnerPrpCoordinator( agentId: input.agentId, }); const semanticTools = await semanticAuthority.listAlwaysAvailableTools(); - const nativeStore = new NativeRunCoordinatorStore(db, { + const storeBinding = { companyId: input.companyId, issueId: input.issueId, runId: input.runId, @@ -225,7 +230,8 @@ export function runnerPrpCoordinator( completionContractSha256: binding.run.completionContractSha256, completionContractRevision: String(binding.completionContract.revision), completionContractCriterionIds: criterionIds, - }); + } as const; + const nativeStore = new NativeRunCoordinatorStore(db, storeBinding); type StoredCompletedRun = NonNullable>>; type CompletedRun = StoredCompletedRun & { readonly providerSessionId?: string }; const withProviderSession = async (stored: StoredCompletedRun): Promise => { @@ -255,6 +261,9 @@ export function runnerPrpCoordinator( connectionLeaseTtlMs, onCommittedEvent: async (event) => { await nativeStore.appendEvent(event); + if (event.eventType === "runtime_request.created") { + await projectNativeRuntimeRequest({ db, binding: storeBinding, event }); + } await nativeStore.reconcileTerminalEvent(event); if (event.eventType === "run.terminal") { const stored = await nativeStore.readCompletedRun(); @@ -274,15 +283,38 @@ export function runnerPrpCoordinator( }, }); - const registration = await registerRunnerPrpAuthority({ - companyId: input.companyId, - runId: input.runId, - authority, - }); + let registration: Awaited>; + try { + registration = await registerRunnerPrpAuthority({ + companyId: input.companyId, + runId: input.runId, + authority, + }); + } catch (error) { + authority.disconnectActiveRunner(); + throw error; + } + let releaseQuestionTarget = () => {}; + try { + releaseQuestionTarget = registerNativeQuestionCommandTarget({ + binding: storeBinding, + queueCommand: (type, payload = {}, commandId) => { + const command = authority.queueCommand(type, payload, commandId, true); + return { commandId: command.commandId, controllerSeq: command.controllerSeq }; + }, + }); + await flushNativeQuestionResponses(db, input.runId); + } catch (error) { + releaseQuestionTarget(); + authority.disconnectActiveRunner(); + await registration.release(); + throw error; + } let bootstrapTicket: string; try { bootstrapTicket = authority.issueBootstrapTicket(bootstrapTtlMs); } catch (error) { + releaseQuestionTarget(); authority.disconnectActiveRunner(); await registration.release(); throw error; @@ -339,6 +371,7 @@ export function runnerPrpCoordinator( release: async () => { if (released) return; released = true; + releaseQuestionTarget(); authority.disconnectActiveRunner(); await registration.release(); }, diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index 1d54d83a5c8..ed71ad6a7e6 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -18,6 +18,9 @@ export type { PaperclipSemanticToolCall, PaperclipSemanticToolDefinition, PaperclipSemanticToolResult, + PaperclipQuestionResponse, + PaperclipQuestionSet, + PaperclipRuntimeInputRequest, PrpEvent, PrpStructuredRunResult, PrpTerminalState, @@ -35,6 +38,8 @@ const runner = await import(sourceUrl.href) as RunnerModule; export const DurablePrpControlPlane = runner.DurablePrpControlPlane; export const PaperclipSemanticDispatcher = runner.PaperclipSemanticDispatcher; +export const parsePaperclipQuestionSet = runner.parsePaperclipQuestionSet; +export const parsePaperclipQuestionResponse = runner.parsePaperclipQuestionResponse; export const validatePrpEvent = runner.validatePrpEvent; export const validatePrpStructuredRunResult = runner.validatePrpStructuredRunResult; diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index 30698b12ffa..b4d9d2baf89 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -5,6 +5,7 @@ export type { RunLivenessState } from "@paperclipai/shared"; export interface RunForIssue { runId: string; + runtimeMode?: "legacy" | "native"; status: string; agentId: string; adapterType: string; diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index c3a9f5ac6ff..1b763b9cd32 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -16,6 +16,7 @@ export interface RunLivenessFields { export interface ActiveRunForIssue { id: string; + runtimeMode?: "legacy" | "native"; status: string; invocationSource: string; triggerDetail: string | null; @@ -45,6 +46,7 @@ export interface ActiveRunForIssue { export interface LiveRunForIssue { id: string; + runtimeMode?: "legacy" | "native"; status: string; invocationSource: string; triggerDetail: string | null; diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 3995a58cdfc..01f1318bd69 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -295,6 +295,32 @@ describe("IssueThreadInteractionCard", () => { expect(host.querySelectorAll('[role="radio"]').length).toBeGreaterThan(0); }); + it("keeps a closed native select set closed while preserving direct-question defaults", () => { + const closed = { + ...pendingAskUserQuestionsInteraction, + payload: { + ...pendingAskUserQuestionsInteraction.payload, + questions: pendingAskUserQuestionsInteraction.payload.questions.map((question) => ({ + ...question, + allowOther: false, + })), + }, + }; + const host = renderCard({ interaction: closed, onSubmitInteractionAnswers: vi.fn() }); + expect(Array.from(host.querySelectorAll("button")).some((button) => button.textContent === "Other")) + .toBe(false); + + act(() => root?.unmount()); + host.remove(); + root = null; + const legacy = renderCard({ + interaction: pendingAskUserQuestionsInteraction, + onSubmitInteractionAnswers: vi.fn(), + }); + expect(Array.from(legacy.querySelectorAll("button")).some((button) => button.textContent === "Other")) + .toBe(true); + }); + it("only shows question cancellation when a cancel handler is wired", () => { const withoutHandler = renderCard({ interaction: pendingAskUserQuestionsInteraction, diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 747ccb378a7..e0dd8a02b51 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -1227,7 +1227,7 @@ function AskUserQuestionsCard({ * free-text option so the card never shows two ways to type an * answer (PAP-419). */} - {hasFreeTextOption ? null : ( + {hasFreeTextOption || question.allowOther === false ? null : ( <>