feat(runner): integrate native runs into task threads

This commit is contained in:
Dotta committed 2026-08-25 18:03:36 -05:00
1 parent a5c2add7be
commit 10560e64be
28 files changed
+1501 -35

No files matched your search

+4
View File
@@ -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",
+3
View File
@@ -692,6 +692,9 @@ export type {
SuggestTasksResult,
AskUserQuestionsQuestionOption,
AskUserQuestionsQuestion,
PaperclipQuestionSetOption,
PaperclipQuestionSetQuestion,
PaperclipQuestionSetPayload,
AskUserQuestionsPayload,
AskUserQuestionsAnswer,
AskUserQuestionsResult,
+42
View File
@@ -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 {
+1
View File
@@ -446,6 +446,7 @@ export {
suggestTasksResultSchema,
askUserQuestionsQuestionOptionSchema,
askUserQuestionsQuestionSchema,
paperclipQuestionSetPayloadSchema,
askUserQuestionsPayloadSchema,
askUserQuestionsAnswerSchema,
askUserQuestionsResultSchema,
+91 -12
View File
@@ -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(),
+2
View File
@@ -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,
+26 -1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -5,6 +5,7 @@ export type { RunLivenessState } from "@paperclipai/shared";
export interface RunForIssue {
runId: string;
runtimeMode?: "legacy" | "native";
status: string;
agentId: string;
adapterType: string;
+2
View File
@@ -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"
+51 -1
View File
@@ -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 () => {}} />);
+21 -1
View File
@@ -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 };
}
+1
View File
@@ -40,6 +40,7 @@ export interface IssueChatComment extends IssueComment {
export interface IssueChatLinkedRun {
runId: string;
runtimeMode?: "legacy" | "native";
status: string;
agentId: string;
adapterType?: string;