mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(runner): integrate native runs into task threads
This commit is contained in:
1 parent
a5c2add7be
commit
10560e64be
28 files changed
+1501
-35
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -692,6 +692,9 @@ export type {
|
||||
SuggestTasksResult,
|
||||
AskUserQuestionsQuestionOption,
|
||||
AskUserQuestionsQuestion,
|
||||
PaperclipQuestionSetOption,
|
||||
PaperclipQuestionSetQuestion,
|
||||
PaperclipQuestionSetPayload,
|
||||
AskUserQuestionsPayload,
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsResult,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -446,6 +446,7 @@ export {
|
||||
suggestTasksResultSchema,
|
||||
askUserQuestionsQuestionOptionSchema,
|
||||
askUserQuestionsQuestionSchema,
|
||||
paperclipQuestionSetPayloadSchema,
|
||||
askUserQuestionsPayloadSchema,
|
||||
askUserQuestionsAnswerSchema,
|
||||
askUserQuestionsResultSchema,
|
||||
|
||||
@@ -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<string>();
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2496,6 +2496,7 @@ const heartbeatRunLogAccessColumns = {
|
||||
|
||||
const heartbeatRunIssueSummaryColumns = {
|
||||
id: heartbeatRuns.id,
|
||||
runtimeMode: heartbeatRuns.runtimeMode,
|
||||
status: heartbeatRuns.status,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
|
||||
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
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<string, unknown>;
|
||||
const input = request.input as Record<string, unknown>;
|
||||
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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>,
|
||||
commandId?: string,
|
||||
) => { readonly commandId: string; readonly controllerSeq: number };
|
||||
|
||||
interface NativeQuestionCommandTarget {
|
||||
binding: Pick<NativeRunStoreBinding, "companyId" | "issueId" | "runId" | "agentId">;
|
||||
queueCommand: QueueCommand;
|
||||
}
|
||||
|
||||
const activeTargets = new Map<string, NativeQuestionCommandTarget>();
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function requestIdForInteraction(
|
||||
interaction: Pick<AskUserQuestionsInteraction, "idempotencyKey" | "sourceRunId" | "payload">,
|
||||
): 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<AskUserQuestionsInteraction, "companyId" | "issueId" | "sourceRunId" | "payload" | "idempotencyKey">,
|
||||
) {
|
||||
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<AskUserQuestionsInteraction | null> {
|
||||
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<string, unknown> },
|
||||
`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<void> {
|
||||
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<string | null> {
|
||||
const run = await authorizedNativeRun(db, interaction);
|
||||
return run && ["queued", "running"].includes(run.status) ? run.id : null;
|
||||
}
|
||||
|
||||
export const nativeQuestionBridgeInternals = {
|
||||
resetForTests: () => activeTargets.clear(),
|
||||
};
|
||||
@@ -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<Awaited<ReturnType<typeof nativeStore.readCompletedRun>>>;
|
||||
type CompletedRun = StoredCompletedRun & { readonly providerSessionId?: string };
|
||||
const withProviderSession = async (stored: StoredCompletedRun): Promise<CompletedRun> => {
|
||||
@@ -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<ReturnType<typeof registerRunnerPrpAuthority>>;
|
||||
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();
|
||||
},
|
||||
|
||||
+5
@@ -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;
|
||||
@@ -5,6 +5,7 @@ export type { RunLivenessState } from "@paperclipai/shared";
|
||||
|
||||
export interface RunForIssue {
|
||||
runId: string;
|
||||
runtimeMode?: "legacy" | "native";
|
||||
status: string;
|
||||
agentId: string;
|
||||
adapterType: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,10 +9,21 @@ import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TaskChatThread } from "./TaskChatThread";
|
||||
|
||||
const transcriptState = vi.hoisted(() => ({ transcriptByRun: new Map() }));
|
||||
const nativeTranscriptState = vi.hoisted(() => ({ transcriptByRun: new Map() }));
|
||||
const transcriptHookRuns = vi.hoisted(() => ({ legacy: [] as unknown[][], native: [] as unknown[][] }));
|
||||
const sidebarState = vi.hoisted(() => ({ isMobile: false }));
|
||||
|
||||
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
|
||||
useLiveRunTranscripts: () => transcriptState,
|
||||
useLiveRunTranscripts: ({ runs }: { runs: unknown[] }) => {
|
||||
transcriptHookRuns.legacy.push(runs);
|
||||
return transcriptState;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/transcript/useNativeRunTranscripts", () => ({
|
||||
useNativeRunTranscripts: (runs: unknown[]) => {
|
||||
transcriptHookRuns.native.push(runs);
|
||||
return nativeTranscriptState;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
useSidebar: () => ({ isMobile: sidebarState.isMobile }),
|
||||
@@ -41,6 +52,9 @@ let root: Root | null = null;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
transcriptState.transcriptByRun.clear();
|
||||
nativeTranscriptState.transcriptByRun.clear();
|
||||
transcriptHookRuns.legacy.length = 0;
|
||||
transcriptHookRuns.native.length = 0;
|
||||
sidebarState.isMobile = false;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -116,6 +130,42 @@ describe("TaskChatThread draft pass-through", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread runtime transcript selection", () => {
|
||||
it("selects persisted runtime facts while leaving direct adapters on the legacy parser", () => {
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
linkedRuns={[
|
||||
{
|
||||
runId: "native-run",
|
||||
runtimeMode: "native",
|
||||
status: "succeeded",
|
||||
agentId: "agent-1",
|
||||
adapterType: "paperclip_runner",
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
},
|
||||
{
|
||||
runId: "legacy-run",
|
||||
runtimeMode: "legacy",
|
||||
status: "succeeded",
|
||||
agentId: "agent-2",
|
||||
adapterType: "codex_local",
|
||||
createdAt: "2026-08-25T18:01:00.000Z",
|
||||
startedAt: "2026-08-25T18:01:00.000Z",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const legacyRuns = transcriptHookRuns.legacy.at(-1) as Array<{ id: string }>;
|
||||
const nativeRuns = transcriptHookRuns.native.at(-1) as Array<{ id: string }>;
|
||||
expect(legacyRuns.map((run) => run.id)).toEqual(["legacy-run"]);
|
||||
expect(nativeRuns.map((run) => run.id)).toEqual(["native-run"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread composer alignment", () => {
|
||||
it("matches the thread width at every breakpoint", () => {
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} />);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useLiveRunTranscripts,
|
||||
type RunTranscriptSource,
|
||||
} from "@/components/transcript/useLiveRunTranscripts";
|
||||
import { useNativeRunTranscripts } from "@/components/transcript/useNativeRunTranscripts";
|
||||
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
import {
|
||||
@@ -215,6 +216,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
id: r.runId,
|
||||
status: r.status,
|
||||
adapterType: r.adapterType ?? "",
|
||||
runtimeMode: r.runtimeMode,
|
||||
hasStoredOutput: r.hasStoredOutput,
|
||||
logBytes: r.logBytes,
|
||||
});
|
||||
@@ -224,6 +226,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
adapterType: r.adapterType,
|
||||
runtimeMode: r.runtimeMode,
|
||||
hasStoredOutput: map.get(r.id)?.hasStoredOutput,
|
||||
logBytes: r.logBytes,
|
||||
lastOutputBytes: r.lastOutputBytes,
|
||||
@@ -234,6 +237,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
id: activeRun.id,
|
||||
status: activeRun.status,
|
||||
adapterType: activeRun.adapterType,
|
||||
runtimeMode: activeRun.runtimeMode,
|
||||
logBytes: activeRun.logBytes,
|
||||
lastOutputBytes: activeRun.lastOutputBytes,
|
||||
});
|
||||
@@ -241,7 +245,23 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
return [...map.values()];
|
||||
}, [linkedRuns, liveRuns, activeRun]);
|
||||
|
||||
const { transcriptByRun } = useLiveRunTranscripts({ runs, companyId });
|
||||
const legacyRuns = useMemo(
|
||||
() => runs.filter((run) => run.runtimeMode !== "native"),
|
||||
[runs],
|
||||
);
|
||||
const nativeRuns = useMemo(
|
||||
() => runs.filter((run) => run.runtimeMode === "native"),
|
||||
[runs],
|
||||
);
|
||||
const { transcriptByRun: legacyTranscriptByRun } = useLiveRunTranscripts({
|
||||
runs: legacyRuns,
|
||||
companyId,
|
||||
});
|
||||
const { transcriptByRun: nativeTranscriptByRun } = useNativeRunTranscripts(nativeRuns);
|
||||
const transcriptByRun = useMemo(
|
||||
() => new Map([...legacyTranscriptByRun, ...nativeTranscriptByRun]),
|
||||
[legacyTranscriptByRun, nativeTranscriptByRun],
|
||||
);
|
||||
|
||||
// The single in-flight run whose turn we stream live (non-terminal).
|
||||
const liveRun = useMemo(() => {
|
||||
|
||||
@@ -9,14 +9,17 @@ import type { TaskChatUsageItem } from "./task-chat-model";
|
||||
*/
|
||||
export function TaskChatUsageReadout({ item }: { item: TaskChatUsageItem }) {
|
||||
const { used, size, inputTokens, outputTokens, costUsd } = item.usage;
|
||||
const pct = size > 0 ? Math.min(100, Math.round((used / size) * 100)) : 0;
|
||||
const contextWindowSize = typeof size === "number" && size > 0 ? size : null;
|
||||
const pct = contextWindowSize ? Math.min(100, Math.round((used / contextWindowSize) * 100)) : 0;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1 py-1 text-(length:--text-micro) text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Gauge className="h-3 w-3" />
|
||||
<span>
|
||||
{used.toLocaleString()}/{size.toLocaleString()} ctx ({pct}%)
|
||||
</span>
|
||||
{contextWindowSize ? (
|
||||
<span>
|
||||
{used.toLocaleString()}/{contextWindowSize.toLocaleString()} ctx ({pct}%)
|
||||
</span>
|
||||
) : null}
|
||||
{inputTokens != null || outputTokens != null ? (
|
||||
<span>
|
||||
· ↑{(inputTokens ?? 0).toLocaleString()} ↓{(outputTokens ?? 0).toLocaleString()}
|
||||
@@ -24,12 +27,14 @@ export function TaskChatUsageReadout({ item }: { item: TaskChatUsageItem }) {
|
||||
) : null}
|
||||
{costUsd != null ? <span>· ${costUsd.toFixed(4)}</span> : null}
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-border">
|
||||
<div
|
||||
className={cn("h-full rounded-full bg-(--status-agent-running)")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{contextWindowSize ? (
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-border">
|
||||
<div
|
||||
className={cn("h-full rounded-full bg-(--status-agent-running)")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,6 +162,51 @@ describe("transcriptToTaskChatItems tool_call updates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcriptToTaskChatItems native usage", () => {
|
||||
it("renders runner usage without inventing a context-window size", () => {
|
||||
const items = transcriptToTaskChatItems([{
|
||||
kind: "result",
|
||||
ts: TS,
|
||||
text: "",
|
||||
inputTokens: 40,
|
||||
outputTokens: 10,
|
||||
cachedTokens: 5,
|
||||
costUsd: 0.02,
|
||||
subtype: "paperclip_runner_usage",
|
||||
isError: false,
|
||||
errors: [],
|
||||
}], { runId: "native-run", running: true });
|
||||
|
||||
expect(items).toEqual([{
|
||||
id: "native-run:usage:0",
|
||||
kind: "usage",
|
||||
usage: {
|
||||
used: 55,
|
||||
size: 0,
|
||||
inputTokens: 40,
|
||||
outputTokens: 10,
|
||||
costUsd: 0.02,
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
it("does not change direct-adapter result presentation", () => {
|
||||
const items = transcriptToTaskChatItems([{
|
||||
kind: "result",
|
||||
ts: TS,
|
||||
text: "done",
|
||||
inputTokens: 40,
|
||||
outputTokens: 10,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "success",
|
||||
isError: false,
|
||||
errors: [],
|
||||
}], { runId: "legacy-run", running: true });
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTurnSummary tool counting", () => {
|
||||
function statusEntry(toolUseId: string | undefined, status: string): TranscriptEntry {
|
||||
return {
|
||||
|
||||
@@ -310,8 +310,26 @@ export function transcriptToTaskChatItems(
|
||||
resetInline();
|
||||
break;
|
||||
}
|
||||
// init / result / stderr / stdout / system / user carry no thread-visible
|
||||
// content in the live turn (status is rendered separately).
|
||||
case "result": {
|
||||
if (entry.subtype !== "paperclip_runner_usage") break;
|
||||
const inputTokens = entry.inputTokens || 0;
|
||||
const outputTokens = entry.outputTokens || 0;
|
||||
items.push({
|
||||
id: `${runId}:usage:${i}`,
|
||||
kind: "usage",
|
||||
usage: {
|
||||
used: inputTokens + outputTokens + (entry.cachedTokens || 0),
|
||||
size: 0,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
...(entry.costUsd > 0 ? { costUsd: entry.costUsd } : {}),
|
||||
},
|
||||
});
|
||||
resetInline();
|
||||
break;
|
||||
}
|
||||
// init / stderr / stdout / system / user and non-runner result entries
|
||||
// carry no thread-visible content (status is rendered separately).
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||
import { nativeRunEventsToTranscript } from "./native-run-events";
|
||||
|
||||
const RUN_ID = "10000000-0000-4000-8000-000000000001";
|
||||
|
||||
function event(
|
||||
seq: number,
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
overrides: Partial<HeartbeatRunEvent> = {},
|
||||
): HeartbeatRunEvent {
|
||||
return {
|
||||
id: seq,
|
||||
companyId: "10000000-0000-4000-8000-000000000002",
|
||||
runId: RUN_ID,
|
||||
agentId: "10000000-0000-4000-8000-000000000003",
|
||||
seq,
|
||||
eventType,
|
||||
stream: "system",
|
||||
level: "info",
|
||||
color: null,
|
||||
message: null,
|
||||
payload: {
|
||||
prpEvent: {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `event-${seq}`,
|
||||
sourceSeq: seq,
|
||||
sourceInstanceId: "runner-1",
|
||||
sourceKind: "runner",
|
||||
runId: RUN_ID,
|
||||
normalizedSessionId: "session-1",
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: 1,
|
||||
emittedAt: `2026-08-25T18:00:${String(seq).padStart(2, "0")}.000Z`,
|
||||
payload,
|
||||
},
|
||||
},
|
||||
createdAt: new Date("2026-08-25T18:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("nativeRunEventsToTranscript", () => {
|
||||
it("projects provider-neutral messages, tools, usage, and the final reply", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
event(6, "run.result.proposed", { summary: "Done safely." }),
|
||||
event(1, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "Done " }),
|
||||
event(2, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "safely." }),
|
||||
event(3, "item.completed", { itemId: "message-1", kind: "agentMessage", text: "Done safely." }),
|
||||
event(4, "tool.execution.started", {
|
||||
executionId: "exec-1",
|
||||
transport: "process",
|
||||
operation: "execute",
|
||||
name: "pnpm test",
|
||||
status: "running",
|
||||
}),
|
||||
event(5, "tool.execution.completed", {
|
||||
executionId: "exec-1",
|
||||
transport: "process",
|
||||
operation: "execute",
|
||||
name: "pnpm test",
|
||||
status: "completed",
|
||||
output: "all green",
|
||||
}),
|
||||
event(7, "usage.reported", {
|
||||
runDelta: {
|
||||
inputTokens: 12,
|
||||
outputTokens: 3,
|
||||
cacheReadTokens: 2,
|
||||
providerCostUsd: 0.01,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(transcript).toEqual([
|
||||
expect.objectContaining({ kind: "assistant", text: "Done safely." }),
|
||||
expect.objectContaining({
|
||||
kind: "tool_call",
|
||||
name: "Bash",
|
||||
toolUseId: "exec-1",
|
||||
input: { command: "pnpm test" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "tool_result",
|
||||
toolUseId: "exec-1",
|
||||
content: "all green",
|
||||
isError: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "result",
|
||||
subtype: "paperclip_runner_usage",
|
||||
inputTokens: 12,
|
||||
outputTokens: 3,
|
||||
cachedTokens: 2,
|
||||
costUsd: 0.01,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("streams deltas until a loss-resistant completed item is available", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "Still " }),
|
||||
event(2, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "working" }),
|
||||
])).toEqual([
|
||||
expect.objectContaining({ kind: "assistant", text: "Still ", delta: true }),
|
||||
expect.objectContaining({ kind: "assistant", text: "working", delta: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the structured run summary when no agent message was emitted", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "run.result.proposed", { summary: "Recovered final reply." }),
|
||||
])).toEqual([
|
||||
expect.objectContaining({ kind: "assistant", text: "Recovered final reply." }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed for malformed, mismatched, and unknown event envelopes", () => {
|
||||
const mismatched = event(1, "item.delta", {
|
||||
itemId: "message-1",
|
||||
kind: "agentMessage",
|
||||
text: "must not render",
|
||||
});
|
||||
(mismatched.payload!.prpEvent as Record<string, unknown>).runId = "other-run";
|
||||
const malformed = event(2, "item.delta", {});
|
||||
malformed.payload = { providerNativeSecret: "must not render" };
|
||||
|
||||
expect(nativeRunEventsToTranscript([
|
||||
mismatched,
|
||||
malformed,
|
||||
event(3, "plan.updated", { explanation: "not a transcript row" }),
|
||||
])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function timestamp(event: HeartbeatRunEvent, envelope: Record<string, unknown>): string {
|
||||
const emittedAt = text(envelope.emittedAt);
|
||||
if (emittedAt) return emittedAt;
|
||||
const createdAt = event.createdAt instanceof Date ? event.createdAt.toISOString() : String(event.createdAt);
|
||||
return Number.isNaN(Date.parse(createdAt)) ? new Date(0).toISOString() : createdAt;
|
||||
}
|
||||
|
||||
function toolPresentation(payload: Record<string, unknown>): { name: string; input: unknown } {
|
||||
const transport = text(payload.transport);
|
||||
const operation = text(payload.operation);
|
||||
const reportedName = text(payload.name);
|
||||
if (transport === "process") {
|
||||
return {
|
||||
name: "Bash",
|
||||
input: reportedName ? { command: reportedName } : { operation: operation ?? "execute" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: reportedName ?? operation ?? "Tool",
|
||||
input: {
|
||||
...(operation ? { operation } : {}),
|
||||
...(text(payload.namespace) ? { namespace: text(payload.namespace) } : {}),
|
||||
...(text(payload.target) ? { target: text(payload.target) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project persisted, provider-neutral PRP events into the legacy transcript
|
||||
* model already consumed by the task thread. Provider-native envelopes never
|
||||
* reach this boundary and unknown event kinds remain safely invisible.
|
||||
*/
|
||||
export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const startedToolIds = new Set<string>();
|
||||
let hasAssistantMessage = false;
|
||||
const orderedEvents = [...events].sort((a, b) => a.seq - b.seq);
|
||||
const completedAgentMessageIds = new Set<string>();
|
||||
for (const event of orderedEvents) {
|
||||
if (event.eventType !== "item.completed") continue;
|
||||
const envelope = record(event.payload?.prpEvent);
|
||||
if (
|
||||
!envelope
|
||||
|| envelope.schema !== "paperclip.prp.event.v1"
|
||||
|| envelope.runId !== event.runId
|
||||
|| envelope.eventType !== event.eventType
|
||||
) continue;
|
||||
const payload = record(envelope?.payload);
|
||||
const itemId = text(payload?.itemId);
|
||||
if (payload?.kind === "agentMessage" && itemId && text(payload.text)) {
|
||||
completedAgentMessageIds.add(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of orderedEvents) {
|
||||
const envelope = record(event.payload?.prpEvent);
|
||||
if (!envelope || envelope.schema !== "paperclip.prp.event.v1") continue;
|
||||
if (envelope.runId !== event.runId || envelope.eventType !== event.eventType) continue;
|
||||
const payload = record(envelope.payload);
|
||||
if (!payload) continue;
|
||||
const ts = timestamp(event, envelope);
|
||||
|
||||
if (event.eventType === "item.delta" && payload.kind === "agentMessage") {
|
||||
const value = text(payload.text);
|
||||
const itemId = text(payload.itemId);
|
||||
if (!value || !itemId) continue;
|
||||
// Once the loss-resistant completion is present, prefer its full text.
|
||||
// Before that point the deltas still provide the live streaming view.
|
||||
if (completedAgentMessageIds.has(itemId)) continue;
|
||||
hasAssistantMessage = true;
|
||||
entries.push({ kind: "assistant", ts, text: value, delta: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "item.completed" && payload.kind === "agentMessage") {
|
||||
const value = text(payload.text);
|
||||
if (!value) continue;
|
||||
hasAssistantMessage = true;
|
||||
entries.push({ kind: "assistant", ts, text: value });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "tool.execution.started" || event.eventType === "tool.execution.completed") {
|
||||
const executionId = text(payload.executionId);
|
||||
if (!executionId) continue;
|
||||
const presentation = toolPresentation(payload);
|
||||
if (!startedToolIds.has(executionId)) {
|
||||
startedToolIds.add(executionId);
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: presentation.name,
|
||||
input: presentation.input,
|
||||
toolUseId: executionId,
|
||||
});
|
||||
}
|
||||
if (event.eventType === "tool.execution.completed") {
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: executionId,
|
||||
toolName: presentation.name,
|
||||
content: text(payload.output) ?? "",
|
||||
isError: payload.status === "failed",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "usage.reported") {
|
||||
const measurement = record(payload.runDelta) ?? record(payload.cumulative);
|
||||
if (!measurement) continue;
|
||||
entries.push({
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "",
|
||||
inputTokens: finiteNumber(measurement.inputTokens),
|
||||
outputTokens: finiteNumber(measurement.outputTokens),
|
||||
cachedTokens: finiteNumber(measurement.cacheReadTokens),
|
||||
costUsd: finiteNumber(measurement.providerCostUsd),
|
||||
subtype: "paperclip_runner_usage",
|
||||
isError: false,
|
||||
errors: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "run.result.proposed" && !hasAssistantMessage) {
|
||||
const summary = text(payload.summary);
|
||||
if (summary) {
|
||||
hasAssistantMessage = true;
|
||||
entries.push({ kind: "assistant", ts, text: summary });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "provider.notice.recorded" && payload.severity === "error") {
|
||||
const summary = text(payload.summary);
|
||||
if (summary) entries.push({ kind: "stderr", ts, text: summary });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export interface RunTranscriptSource {
|
||||
id: string;
|
||||
status: string;
|
||||
adapterType: string;
|
||||
runtimeMode?: "legacy" | "native";
|
||||
hasStoredOutput?: boolean;
|
||||
logBytes?: number | null;
|
||||
lastOutputBytes?: number | null;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import { heartbeatsApi } from "@/api/heartbeats";
|
||||
import { nativeRunEventsToTranscript } from "./native-run-events";
|
||||
|
||||
const EVENT_PAGE_SIZE = 1_000;
|
||||
const EVENT_POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
export interface NativeRunTranscriptSource {
|
||||
id: string;
|
||||
status: string;
|
||||
runtimeMode?: "legacy" | "native";
|
||||
}
|
||||
|
||||
function isLive(status: string): boolean {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource[]) {
|
||||
const nativeRunsKey = runs
|
||||
.filter((run) => run.runtimeMode === "native")
|
||||
.map((run) => `${run.id}:${run.status}`)
|
||||
.sort()
|
||||
.join(",");
|
||||
const nativeRuns = useMemo(
|
||||
() => runs.filter((run) => run.runtimeMode === "native").map((run) => ({ ...run })),
|
||||
// The key carries every field this hook consumes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[nativeRunsKey],
|
||||
);
|
||||
const [eventsByRun, setEventsByRun] = useState<Map<string, HeartbeatRunEvent[]>>(new Map());
|
||||
const cursorByRunRef = useRef(new Map<string, number>());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const refresh = async () => {
|
||||
const updates = new Map<string, HeartbeatRunEvent[]>();
|
||||
await Promise.all(nativeRuns.map(async (run) => {
|
||||
try {
|
||||
let cursor = cursorByRunRef.current.get(run.id) ?? 0;
|
||||
const incoming: HeartbeatRunEvent[] = [];
|
||||
for (;;) {
|
||||
const page = await heartbeatsApi.events(run.id, cursor, EVENT_PAGE_SIZE);
|
||||
if (cancelled) return;
|
||||
const last = page.at(-1);
|
||||
const nextCursor = last ? Math.max(cursor, last.seq) : cursor;
|
||||
incoming.push(...page);
|
||||
if (page.length < EVENT_PAGE_SIZE || nextCursor === cursor) {
|
||||
cursor = nextCursor;
|
||||
break;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
}
|
||||
if (incoming.length > 0) updates.set(run.id, incoming);
|
||||
cursorByRunRef.current.set(run.id, cursor);
|
||||
} catch {
|
||||
// Keep the last durable cursor; the next poll retries this run only.
|
||||
}
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
const retainedIds = new Set(nativeRuns.map((run) => run.id));
|
||||
for (const runId of cursorByRunRef.current.keys()) {
|
||||
if (!retainedIds.has(runId)) cursorByRunRef.current.delete(runId);
|
||||
}
|
||||
setEventsByRun((previous) => {
|
||||
const next = new Map<string, HeartbeatRunEvent[]>();
|
||||
for (const runId of retainedIds) {
|
||||
const current = previous.get(runId) ?? [];
|
||||
const incoming = updates.get(runId) ?? [];
|
||||
next.set(runId, incoming.length > 0 ? [...current, ...incoming] : current);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (nativeRuns.some((run) => isLive(run.status))) {
|
||||
timer = window.setTimeout(refresh, EVENT_POLL_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [nativeRuns]);
|
||||
|
||||
const transcriptByRun = useMemo(() => {
|
||||
const transcripts = new Map<string, TranscriptEntry[]>();
|
||||
for (const run of nativeRuns) {
|
||||
transcripts.set(run.id, nativeRunEventsToTranscript(eventsByRun.get(run.id) ?? []));
|
||||
}
|
||||
return transcripts;
|
||||
}, [eventsByRun, nativeRuns]);
|
||||
|
||||
return { transcriptByRun };
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export interface IssueChatComment extends IssueComment {
|
||||
|
||||
export interface IssueChatLinkedRun {
|
||||
runId: string;
|
||||
runtimeMode?: "legacy" | "native";
|
||||
status: string;
|
||||
agentId: string;
|
||||
adapterType?: string;
|
||||
|
||||
Reference in new issue
Block a user