mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Add company-scoped Muse native control, OAuth, wake receiver and recovery. Recheck current authority after waits and before effects or receipt replay. Preserve task-read scope, assignment expiry, independent holds, nonblocking reassignment and truthful cleanup provenance. Retain source trust on progress comments and verify current Muse V8 authority through real signed-JWT HTTP requests. Preserve the upstream trimmed ordinary authentication lookup. Refs #16448. Preserve captured canonical queue recovery and concurrent environment preparation while retaining the scoped Muse V8 exclusion. Normal migration 0354 and existing Muse SQL remain unchanged. Fresh current-head Greptile review is 5/5 with the project-write finding addressed. Required CI and independent technical review gate the merge; maintainer override is reserved for formal approval rules. Recheck current Muse project-write authority after all receipt/task/run/admission waits, before creation or replay. Real PostgreSQL regressions reproduce four late effects/replays on the old route; the repaired three-file treatment passes 354 cases with no skips, and the scoped server typecheck passes. Ordinary/Dot behavior is preserved. Co-Authored-By: Paperclip <noreply@paperclip.ing>
780 lines
35 KiB
TypeScript
780 lines
35 KiB
TypeScript
import { createLocalNativeQuestionBridge } from "./local-native-question-bridge.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { createServer } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq, sql } from "drizzle-orm";
|
|
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
authUsers,
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
heartbeatRuns,
|
|
heartbeatRunEvents,
|
|
issueComments,
|
|
issueQuestionResponseDeliveries,
|
|
issueThreadInteractions,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
} from "@paperclipai/db";
|
|
import type { PrpEvent } from "@paperclipai/paperclip-runner";
|
|
import { respondIssueThreadInteractionSchema } from "@paperclipai/shared";
|
|
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { drainHeartbeatRunsToQuiescence } from "../../__tests__/helpers/drain-heartbeat-runs.js";
|
|
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
|
import {
|
|
deliverNativeQuestionResponse,
|
|
flushNativeQuestionResponses,
|
|
nativeQuestionBridgeInternals,
|
|
nativeQuestionRunToCancel,
|
|
projectNativeRuntimeRequest,
|
|
registerNativeQuestionCommandTarget,
|
|
requestNativeQuestionRunCancellation,
|
|
validateNativeQuestionResponseInput,
|
|
} from "./native-question-bridge.js";
|
|
import {
|
|
executeIssuePostCommitActions,
|
|
issueService,
|
|
type IssuePostCommitAction,
|
|
} from "../issues.js";
|
|
import { questionResponseDeliveryService } from "../question-response-delivery.js";
|
|
import * as questionPatternValidation from "../question-pattern-validation.js";
|
|
import { heartbeatService } from "../heartbeat.js";
|
|
import { DurablePrpControlPlane } from "../../vendor/paperclip-runner/index.js";
|
|
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
|
|
import { readPendingNativeRuntimeRequest } from "./runtime-request-resolution-authority.js";
|
|
import {
|
|
queueRunnerPrpRuntimeRequestResolution,
|
|
registerRunnerPrpAuthority,
|
|
setupRunnerPrpWebSocketServer,
|
|
} from "../../realtime/runner-prp-ws.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 heartbeat: ReturnType<typeof heartbeatService>;
|
|
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);
|
|
heartbeat = heartbeatService(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
// A cancelled or reaped run can promote and dispatch its agent's next
|
|
// queued run fire-and-forget (see startNextQueuedRunForAgent in
|
|
// heartbeat.ts), so that dispatch can still be writing heartbeat_runs,
|
|
// issues, or activity_log rows when this hook starts. Drain every
|
|
// in-flight run to quiescence first, or its late write races the
|
|
// TRUNCATE below and can deadlock.
|
|
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
|
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 () => {
|
|
await heartbeat.drainActiveRunExecutions();
|
|
await 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(authUsers).values({
|
|
id: "operator-1", name: "Operator", email: "operator-1@example.test",
|
|
createdAt: new Date(), updatedAt: new Date(),
|
|
}).onConflictDoNothing();
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: "operator-1", status: "active", membershipRole: "member",
|
|
});
|
|
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,
|
|
responsibleUserId: "operator-1",
|
|
});
|
|
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 },
|
|
});
|
|
await db.insert(nativeRunFinalizations).values({
|
|
runId,
|
|
companyId,
|
|
issueId,
|
|
phase: "observed",
|
|
});
|
|
}
|
|
|
|
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: [],
|
|
};
|
|
}
|
|
|
|
function permissionRequestEvent(): PrpEvent {
|
|
return { ...runtimeRequestEvent(), payload: { request: {
|
|
schema: "paperclip.runtime_request.v2", requestKind: "permission_approval",
|
|
requestId: "permission-1", turnId: "turn-1", itemId: "item-1",
|
|
type: "permission", status: "pending", prompt: "Allow editing src/example.ts?",
|
|
choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }],
|
|
details: { toolCallId: "tool-1" },
|
|
origin: { adapter: "acpx-runtime-sidecar", provider: "acpx", method: "session/request_permission" },
|
|
} } };
|
|
}
|
|
|
|
it("commits and acknowledges an ACP permission, then queues only an admin's exact turn-bound decision", async () => {
|
|
await seed();
|
|
const event = permissionRequestEvent();
|
|
const projection = vi.fn(async (committed: PrpEvent) => {
|
|
await projectNativeRuntimeRequest({ db, binding: binding(), event: committed });
|
|
});
|
|
const port = new PaperclipControlPlanePort(db, {
|
|
companyId, issueId, runId, agentId, sessionId,
|
|
completionContractId: binding().completionContractId,
|
|
completionContractSha256: binding().completionContractSha256,
|
|
sourceInstanceId: runnerInstanceId, controlPlaneSourceInstanceId: "control-1",
|
|
}, { onCommittedEvent: projection });
|
|
const receipt = await port.appendEvent(event);
|
|
expect(receipt).toMatchObject({ disposition: "committed", highestContiguousSourceSeq: 1 });
|
|
expect(projection).toHaveBeenCalledOnce();
|
|
expect(await db.select().from(issueThreadInteractions)).toHaveLength(0);
|
|
const [persisted] = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId));
|
|
expect(persisted?.payload).toEqual({ prpEvent: event }); // task-chat's exact source, with only offered decisions
|
|
expect(await port.appendEvent(event)).toMatchObject({ disposition: "duplicate" });
|
|
const pending = await readPendingNativeRuntimeRequest(db, { companyId, runId, requestId: "permission-1" });
|
|
expect(pending).toMatchObject({ requestKind: "permission_approval", turnId: "turn-1", resolverPolicy: "instance_admin" });
|
|
expect(await readPendingNativeRuntimeRequest(db, { companyId: randomUUID(), runId, requestId: "permission-1" })).toBeNull();
|
|
|
|
const stateDirectory = mkdtempSync(join(tmpdir(), "native-permission-authority-"));
|
|
const server = createServer();
|
|
setupRunnerPrpWebSocketServer(server, { apiUrl: "http://127.0.0.1:3213" });
|
|
const authority = new DurablePrpControlPlane({
|
|
stateDirectory,
|
|
identity: { runnerInstanceId, environmentLeaseId: "lease-1", runId, normalizedSessionId: sessionId, turnId: "turn-1", itemId: "item-1" },
|
|
expectedRunnerVersion: "0.3.0", expectedRunnerDigest: `sha256:${"a".repeat(64)}`,
|
|
});
|
|
const registration = await registerRunnerPrpAuthority({ companyId, runId, authority });
|
|
try {
|
|
const commandInput = { companyId, runId, pendingRequest: pending!, resolution: { action: "accept" as const },
|
|
actor: { type: "user" as const, userId: "admin-1", isInstanceAdmin: true } };
|
|
expect(() => queueRunnerPrpRuntimeRequestResolution({ ...commandInput, actor: { ...commandInput.actor, isInstanceAdmin: false } }))
|
|
.toThrow("native_runtime_request_resolver_denied");
|
|
expect(() => queueRunnerPrpRuntimeRequestResolution({ ...commandInput, companyId: randomUUID() }))
|
|
.toThrow("runner_prp_authority_not_active");
|
|
const queued = queueRunnerPrpRuntimeRequestResolution(commandInput);
|
|
expect(queueRunnerPrpRuntimeRequestResolution(commandInput)).toEqual(queued);
|
|
expect(authority.store.state.commands).toHaveLength(1);
|
|
expect(authority.store.state.commands[0]).toMatchObject({ type: "request.resolve", payload: {
|
|
requestId: "permission-1", requestKind: "permission_approval", turnId: "turn-1",
|
|
resolution: { action: "accept" }, resolutionActor: commandInput.actor,
|
|
} });
|
|
expect(() => queueRunnerPrpRuntimeRequestResolution({ ...commandInput, resolution: { action: "decline" } }))
|
|
.toThrow("runtime_request_resolution_conflict");
|
|
// Queueing is not delivery: only the runner's post-write receipt closes
|
|
// the request. ACP sidecar delivery tests exercise that provider edge.
|
|
expect(await readPendingNativeRuntimeRequest(db, { companyId, runId, requestId: "permission-1" })).toEqual(pending);
|
|
await port.appendEvent({ ...event, sourceEventId: "permission-delivered", sourceSeq: 2,
|
|
eventType: "runtime_request.resolved", payload: { requestId: "permission-1", requestKind: "permission_approval",
|
|
turnId: "turn-1", itemId: "item-1", status: "delivered", action: "accept" } });
|
|
expect(await readPendingNativeRuntimeRequest(db, { companyId, runId, requestId: "permission-1" })).toBeNull();
|
|
} finally {
|
|
await registration.release();
|
|
await authority.stop();
|
|
server.close();
|
|
rmSync(stateDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ requestKind: "runtime" }, { type: "input" }, { turnId: "stale-turn" }, { itemId: "wrong-item" },
|
|
{ choices: [] }, { choices: [{ key: "allow_forever", label: "Always" }] },
|
|
{ choices: [{ key: "accept", label: "One" }, { key: "accept", label: "Two" }] },
|
|
])("rejects a malformed permission projection %j", async (override) => {
|
|
await seed();
|
|
const event = permissionRequestEvent();
|
|
Object.assign(event.payload.request as object, override);
|
|
await expect(projectNativeRuntimeRequest({ db, binding: binding(), event })).rejects.toThrow(/native_runtime_/);
|
|
});
|
|
|
|
it("projects an executor question immediately and routes its durable answer into the same live turn", async () => {
|
|
await seed();
|
|
const event = runtimeRequestEvent();
|
|
await db.insert(heartbeatRunEvents).values({ companyId, agentId, runId, seq: 1,
|
|
eventType: event.eventType, stream: "system", level: "info", payload: { prpEvent: event } });
|
|
const resolve = vi.fn(async (input: any) => { await input.authorizeBeforeDispatch(); return { commandId: "live-response" }; });
|
|
const bridge = createLocalNativeQuestionBridge({ db, binding: binding(), resolve });
|
|
try {
|
|
await bridge.attach();
|
|
await bridge.observe(event);
|
|
await bridge.observe(event); // replay must not create a second card
|
|
const cards = await issueThreadInteractionService(db).listForIssue(issueId);
|
|
expect(cards).toHaveLength(1);
|
|
expect(cards[0]).toMatchObject({ status: "pending", sourceRunId: runId, continuationPolicy: "none" });
|
|
const answered = await issueThreadInteractionService(db).answerQuestions(
|
|
{ id: issueId, companyId, status: "in_progress" }, cards[0]!.id,
|
|
{ answers: [{ questionId: "color", optionIds: ["green"] }] }, { userId: "operator-1" },
|
|
);
|
|
if (answered.kind !== "ask_user_questions") throw new Error("wrong question kind");
|
|
expect(await deliverNativeQuestionResponse(db, answered)).toBe("queued");
|
|
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ runId, requestId: "request-1", turnId: "turn-1", commandId: `question_${cards[0]!.id}`,
|
|
resolution: { action: "submit", response: { schema: "paperclip.question_response.v1", answers: { color: { selectedOptionIds: ["green"] } } } },
|
|
}));
|
|
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId));
|
|
await expect(resolve.mock.calls[0]![0].authorizeBeforeDispatch()).rejects.toThrow("native_question_not_pending");
|
|
} finally { bridge.close(); }
|
|
});
|
|
|
|
it("delivers saved answers without entering pattern validation again", async () => {
|
|
await seed();
|
|
const event = runtimeRequestEvent();
|
|
(event.payload.request as any).input.questions = [{ id: "url", prompt: "URL?", required: true, answerMode: "text" }];
|
|
const interaction = await projectNativeRuntimeRequest({ db, binding: binding(), event });
|
|
const answered = await issueThreadInteractionService(db).answerQuestions(
|
|
{ id: issueId, companyId, status: "in_progress" }, interaction!.id,
|
|
{ answers: [{ questionId: "url", optionIds: [], otherText: "https://example.test" }] }, { userId: "operator-1" },
|
|
);
|
|
if (answered.kind !== "ask_user_questions") throw new Error("expected questions");
|
|
const queueCommand = vi.fn(() => ({ commandId: "question", controllerSeq: 1 }));
|
|
const release = registerNativeQuestionCommandTarget({ binding: { companyId, issueId, runId, agentId }, queueCommand });
|
|
const busy = vi.spyOn(questionPatternValidation, "validateQuestionPatterns").mockRejectedValue(new Error("Question format validation is busy; try again"));
|
|
try {
|
|
await flushNativeQuestionResponses(db, runId);
|
|
expect(queueCommand).toHaveBeenCalledWith("request.resolve", expect.objectContaining({
|
|
response: { schema: "paperclip.question_response.v1", answers: { url: { text: "https://example.test" } } },
|
|
}), `question_${interaction!.id}`);
|
|
expect(busy).not.toHaveBeenCalled();
|
|
const [delivery] = await db.select().from(issueQuestionResponseDeliveries);
|
|
expect(delivery).toMatchObject({ status: "delivered" });
|
|
} finally {
|
|
busy.mockRestore();
|
|
release();
|
|
}
|
|
});
|
|
|
|
it.each(["codex", "claude"])("materializes, validates, and durably resumes a %s question response", async (provider) => {
|
|
await seed();
|
|
const interaction = await projectNativeRuntimeRequest({
|
|
db,
|
|
binding: binding(),
|
|
event: { ...runtimeRequestEvent(), payload: {
|
|
request: { ...(runtimeRequestEvent().payload.request as Record<string, unknown>),
|
|
origin: { adapter: provider === "claude" ? "acpx-runtime-sidecar" : "codex-app-server", provider,
|
|
method: provider === "claude" ? "elicitation/create" : "item/tool/requestUserInput" },
|
|
},
|
|
} },
|
|
});
|
|
|
|
expect(interaction).toMatchObject({
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
sourceRunId: runId,
|
|
continuationPolicy: "none",
|
|
effectiveResolverPolicy: "human_only",
|
|
payload: {
|
|
runtimeRequestId: "request-1",
|
|
supersedeOnUserComment: false,
|
|
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"] }] };
|
|
await validateNativeQuestionResponseInput(interaction!, answer);
|
|
await expect(validateNativeQuestionResponseInput(interaction!, {
|
|
answers: [{ questionId: "color", optionIds: ["red"] }],
|
|
})).rejects.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(queueCommand).toHaveBeenCalledTimes(1);
|
|
const [delivery] = await db.select().from(issueQuestionResponseDeliveries);
|
|
expect(delivery).toMatchObject({
|
|
interactionId: interaction!.id,
|
|
status: "delivered",
|
|
deliveryMode: "steered",
|
|
targetRunId: runId,
|
|
});
|
|
expect(answered.kind).toBe("ask_user_questions");
|
|
if (answered.kind !== "ask_user_questions") throw new Error("expected question interaction");
|
|
await expect(nativeQuestionRunToCancel(db, answered)).resolves.toBe(runId);
|
|
release();
|
|
});
|
|
|
|
it.each(["legacy", "canonical_select"])("keeps %s custom-answer normalization and rejects blank public answers", async (mode) => {
|
|
await seed();
|
|
const event = runtimeRequestEvent();
|
|
const input = (event.payload.request as { input: { questions: Record<string, unknown>[] } }).input;
|
|
input.questions[0]!.customAnswer = { enabled: true };
|
|
const interaction = await projectNativeRuntimeRequest({ db, binding: binding(), event });
|
|
if (mode === "legacy") {
|
|
const payload = { ...interaction!.payload };
|
|
if ("questionSet" in payload) delete payload.questionSet;
|
|
await db.update(issueThreadInteractions).set({ payload }).where(eq(issueThreadInteractions.id, interaction!.id));
|
|
}
|
|
const service = issueThreadInteractionService(db);
|
|
await expect(service.answerQuestions({ id: issueId, companyId, status: "in_progress" }, interaction!.id,
|
|
respondIssueThreadInteractionSchema.parse({ answers: [{ questionId: "color", optionIds: [], otherText: " \n " }] }), { userId: "operator-1" },
|
|
)).rejects.toThrow(/requires an answer/);
|
|
const answered = await service.answerQuestions({ id: issueId, companyId, status: "in_progress" }, interaction!.id,
|
|
respondIssueThreadInteractionSchema.parse({ answers: [{ questionId: "color", optionIds: [], otherText: " Extra\\nline " }] }), { userId: "operator-1" },
|
|
);
|
|
expect(answered.result).toMatchObject({ answers: [{ questionId: "color", otherText: "Extra\nline" }] });
|
|
});
|
|
|
|
it("persists initial text without resolving and delivers only an explicit edited answer", async () => {
|
|
await seed();
|
|
const initialText = " Editable draft\n漢字\n";
|
|
const event = runtimeRequestEvent();
|
|
const request = event.payload.request as Record<string, unknown>;
|
|
request.input = { schema: "paperclip.question_set.v1", questions: [{ id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText }] };
|
|
const interaction = await projectNativeRuntimeRequest({ db, binding: binding(), event });
|
|
expect(interaction).toMatchObject({ status: "pending", payload: { questionSet: { questions: [{ initialText }] } } });
|
|
const [stored] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction!.id));
|
|
expect(stored).toMatchObject({ status: "pending", result: null, payload: { questionSet: { questions: [{ initialText }] } } });
|
|
const queueCommand = vi.fn(() => ({ commandId: "edited", controllerSeq: 1 }));
|
|
const release = registerNativeQuestionCommandTarget({ binding: { companyId, issueId, runId, agentId }, queueCommand });
|
|
try {
|
|
await flushNativeQuestionResponses(db, runId);
|
|
expect(queueCommand).not.toHaveBeenCalled();
|
|
const edited = "\n Operator 漢字 edited\n\nLiteral \\n stays literal. \n";
|
|
await issueThreadInteractionService(db).answerQuestions(
|
|
{ id: issueId, companyId, status: "in_progress" }, interaction!.id,
|
|
respondIssueThreadInteractionSchema.parse({ answers: [{ questionId: "draft", optionIds: [], otherText: edited }] }), { userId: "operator-1" },
|
|
);
|
|
await flushNativeQuestionResponses(db, runId);
|
|
expect(queueCommand).toHaveBeenCalledExactlyOnceWith("request.resolve", {
|
|
requestId: "request-1", response: { schema: "paperclip.question_response.v1", answers: { draft: { text: edited } } },
|
|
}, `question_${interaction!.id}`);
|
|
} finally { release(); }
|
|
});
|
|
|
|
it.each(["succeeded", "failed", "cancelled", "timed_out"])("delivers a historical answer exactly once after a %s native run", async status => {
|
|
await seed();
|
|
await db.update(issues).set({ conversationAgentId: agentId, conversationUserId: "operator-1", conversationState: "active", conversationSessionGeneration: 1, executionRunId: null }).where(eq(issues.id, issueId));
|
|
const interaction = await projectNativeRuntimeRequest({ db, binding: binding(), event: runtimeRequestEvent() });
|
|
await db.update(heartbeatRuns).set({ status, finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
|
|
await issueThreadInteractionService(db).answerQuestions({ id: issueId, companyId, status: "in_progress" }, interaction!.id,
|
|
{ answers: [{ questionId: "color", optionIds: ["green"] }] }, { userId: "operator-1" });
|
|
const queueCommand = vi.fn();
|
|
const release = registerNativeQuestionCommandTarget({ binding: { companyId, issueId, runId, agentId }, queueCommand });
|
|
const targetRunId = randomUUID();
|
|
const wakeup = vi.fn(async () => db.insert(heartbeatRuns).values({ id: targetRunId, companyId, agentId, status: "queued",
|
|
contextSnapshot: { issueId } }).returning().then(rows => rows[0]!));
|
|
const service = questionResponseDeliveryService(db, { heartbeat: { wakeup } as never,
|
|
resolveNativeQuestion: candidate => deliverNativeQuestionResponse(db, candidate) });
|
|
expect(await service.deliver(interaction!.id)).toMatchObject({ status: "fallback_queued", targetRunId });
|
|
expect((await service.deliver(interaction!.id))?.duplicate).toBe(true);
|
|
expect(wakeup).toHaveBeenCalledTimes(1);
|
|
expect(JSON.stringify(wakeup.mock.calls)).toContain(interaction!.id);
|
|
const [saved] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction!.id));
|
|
expect(saved).toMatchObject({ status: "answered", result: { answers: [{ questionId: "color", optionIds: ["green"] }] } });
|
|
const [delivery] = await db.select().from(issueQuestionResponseDeliveries).where(eq(issueQuestionResponseDeliveries.interactionId, interaction!.id));
|
|
expect(delivery).toMatchObject({ targetRunId, sourceRunId: runId, deliveryMode: "wake_fallback", status: "fallback_queued" });
|
|
expect(queueCommand).not.toHaveBeenCalled();
|
|
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);
|
|
});
|
|
|
|
async function addLaterHumanDirection(interactionId: string) {
|
|
const [question] = await db.select().from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId));
|
|
await db.insert(issueComments).values({ companyId, issueId, authorType: "user", authorUserId: "operator-1",
|
|
body: "Continue with the configuration already selected.", createdAt: new Date(question.createdAt.getTime() + 1000) });
|
|
}
|
|
|
|
it.each([false, true])("cancels the active native run when the task is cancelled (historical=%s)", async historical => {
|
|
await seed();
|
|
const interaction = await projectNativeRuntimeRequest({
|
|
db,
|
|
binding: binding(),
|
|
event: runtimeRequestEvent(),
|
|
});
|
|
if (historical) await addLaterHumanDirection(interaction!.id);
|
|
await issueService(db).update(issueId, { status: "cancelled" });
|
|
|
|
const [persistedInteraction] = await db.select({ status: issueThreadInteractions.status })
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interaction!.id));
|
|
const [persistedRun] = await db.select({
|
|
status: heartbeatRuns.status,
|
|
resultJson: heartbeatRuns.resultJson,
|
|
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
expect(persistedInteraction?.status).toBe("expired");
|
|
expect(persistedRun).toMatchObject({
|
|
status: "cancelled",
|
|
resultJson: {
|
|
cancelledByIssueStatus: "cancelled",
|
|
cancelledIssueId: issueId,
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([false, true])("defers native cancellation until task completion commits (historical=%s)", async historical => {
|
|
await seed();
|
|
const interaction = await projectNativeRuntimeRequest({
|
|
db,
|
|
binding: binding(),
|
|
event: runtimeRequestEvent(),
|
|
});
|
|
if (historical) await addLaterHumanDirection(interaction!.id);
|
|
const postCommitActions: IssuePostCommitAction[] = [];
|
|
await db.transaction(async (tx) => {
|
|
await issueService(db).update(
|
|
issueId,
|
|
{ status: "done" },
|
|
tx,
|
|
undefined,
|
|
postCommitActions,
|
|
);
|
|
const [runInsideTransaction] = await tx.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
expect(runInsideTransaction?.status).toBe("running");
|
|
});
|
|
|
|
expect(postCommitActions).toHaveLength(1);
|
|
await executeIssuePostCommitActions(db, postCommitActions);
|
|
const [persistedRun] = await db.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
expect(persistedRun?.status).toBe("cancelled");
|
|
const [question] = await db.select().from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interaction!.id));
|
|
expect(question.status).toBe(historical ? "pending" : "expired");
|
|
if (historical) {
|
|
await issueThreadInteractionService(db).answerQuestions({ id: issueId, companyId, status: "done" }, interaction!.id,
|
|
{ answers: [{ questionId: "color", optionIds: ["green"] }] }, { userId: "operator-1" });
|
|
expect(await db.select().from(issueQuestionResponseDeliveries)).toEqual([]);
|
|
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("done");
|
|
}
|
|
});
|
|
|
|
it.each([false, true])("recovers task closure cancellation after process exit (historical=%s)", async historical => {
|
|
await seed();
|
|
const interaction = await projectNativeRuntimeRequest({
|
|
db,
|
|
binding: binding(),
|
|
event: runtimeRequestEvent(),
|
|
});
|
|
if (historical) await addLaterHumanDirection(interaction!.id);
|
|
const postCommitActions: IssuePostCommitAction[] = [];
|
|
await db.transaction(async (tx) => {
|
|
await issueService(db).update(
|
|
issueId,
|
|
{ status: "done" },
|
|
tx,
|
|
undefined,
|
|
postCommitActions,
|
|
);
|
|
});
|
|
|
|
const [markedRun] = await db.select({
|
|
status: heartbeatRuns.status,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
expect(markedRun).toMatchObject({
|
|
status: "running",
|
|
contextSnapshot: {
|
|
nativeQuestionCancellation: {
|
|
version: 1,
|
|
issueId,
|
|
issueStatus: "done",
|
|
},
|
|
},
|
|
});
|
|
|
|
// Simulate process exit before executeIssuePostCommitActions can run.
|
|
await heartbeat.reapOrphanedRuns();
|
|
|
|
const [persistedRun] = await db.select({
|
|
status: heartbeatRuns.status,
|
|
resultJson: heartbeatRuns.resultJson,
|
|
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
expect(persistedRun).toMatchObject({
|
|
status: "cancelled",
|
|
resultJson: {
|
|
cancelledByIssueStatus: "done",
|
|
cancelledIssueId: issueId,
|
|
},
|
|
});
|
|
const [question] = await db.select().from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interaction!.id));
|
|
expect(question.status).toBe(historical ? "pending" : "expired");
|
|
});
|
|
|
|
it("recovers an explicit question withdrawal committed with its cancellation intent", async () => {
|
|
await seed();
|
|
const interaction = await projectNativeRuntimeRequest({
|
|
db,
|
|
binding: binding(),
|
|
event: runtimeRequestEvent(),
|
|
});
|
|
await issueThreadInteractionService(db).withdrawInteraction(
|
|
{ id: issueId, companyId },
|
|
interaction!.id,
|
|
{ reason: "No longer needed" },
|
|
{ userId: "operator-1" },
|
|
{
|
|
afterResolveInTransaction: async (tx, withdrawn) => {
|
|
await expect(requestNativeQuestionRunCancellation(tx, withdrawn, {
|
|
kind: "interaction_withdrawn",
|
|
interactionId: withdrawn.id,
|
|
})).resolves.toBe(runId);
|
|
},
|
|
},
|
|
);
|
|
|
|
const [markedRun] = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
expect(markedRun?.contextSnapshot).toMatchObject({
|
|
nativeQuestionCancellation: {
|
|
version: 1,
|
|
kind: "interaction_withdrawn",
|
|
interactionId: interaction!.id,
|
|
issueId,
|
|
},
|
|
});
|
|
|
|
await heartbeat.reapOrphanedRuns();
|
|
|
|
const [cancelledRun] = await db.select({
|
|
status: heartbeatRuns.status,
|
|
resultJson: heartbeatRuns.resultJson,
|
|
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
expect(cancelledRun).toMatchObject({
|
|
status: "cancelled",
|
|
resultJson: {
|
|
withdrawnInteractionId: interaction!.id,
|
|
cancelledIssueId: issueId,
|
|
},
|
|
});
|
|
});
|
|
|
|
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",
|
|
}],
|
|
};
|
|
await 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}`,
|
|
);
|
|
});
|
|
});
|