fix: preserve review requirements, output links, and decision outcomes

This commit is contained in:
scotttong committed 2026-10-27 22:12:56 -07:00
1 parent 50201a1f5a
commit faa52c7bf2
14 files changed
+153 -21

No files matched your search

@@ -127,6 +127,16 @@ const support = await getEmbeddedPostgresTestSupport();
}
});
it("persists required output review supplied through MCP creation", async () => {
const f = await server.fixture({ conversation: true });
const requirement = { version: 1, deliverables: [{ key: "copy", label: "Launch copy" }] };
const result = await callProjectTool({ name: "create_task", arguments: {
title: "MCP review gate", projectId: f.projectId, idempotencyKey: "mcp-quality-gate", requiredQualityReview: requirement,
}, apiUrl: server.apiUrl, token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true }) as any;
const [task] = await server.db.select().from(issues).where(eq(issues.id, result.id));
expect(task.executionPolicy).toMatchObject({ requiredQualityReview: requirement });
});
it("rejects creation, child helpers, and reparenting under a chat, while retaining legacy children", async () => {
const f = await server.fixture({ conversation: true });
const svc = issueService(server.db);
+22 -1
View File
@@ -2,11 +2,17 @@ import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, decisions, decisionTargetIssues, heartbeatRuns, issues, issueThreadInteractions } from "@paperclipai/db";
import { decisionHistoryService, decodeDecisionHistoryCursor, decisionHistoryHref, decisionHistoryTarget } from "../services/decision-history.js";
import { decisionHistoryService, decodeDecisionHistoryCursor, decisionHistoryHref, decisionHistoryTarget, itemVerdictsHistoryLabel } from "../services/decision-history.js";
import { documentService } from "../services/documents.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
describe("decision receipt navigation", () => {
it("preserves all verdict outcomes in a mixed review summary", () => {
expect(itemVerdictsHistoryLabel({ outcome: "resolved", items: [{ verdict: "approve" }, { verdict: "reject" }, { verdict: "defer" }] }))
.toBe("1 approved · 1 rejected · 1 deferred");
expect(itemVerdictsHistoryLabel({ outcome: "resolved", items: [{ verdict: "reject" }] })).toBe("1 rejected");
expect(itemVerdictsHistoryLabel({ outcome: "cancelled", items: [{ verdict: "approve" }] })).toBeNull();
});
it("pins the reviewed document and interaction and ignores arbitrary custom URLs", () => {
const target = decisionHistoryTarget({ target: { type: "issue_document", key: "launch copy", revisionId: "old-revision", href: "https://untrusted.example" } });
expect(decisionHistoryHref("PAP", "PAP-1", "issue_thread_interaction", "interaction", target)).toBe("/PAP/issues/PAP-1#document-launch%20copy&revision=old-revision&interaction=interaction");
@@ -33,6 +39,21 @@ const support = await getEmbeddedPostgresTestSupport();
}, 30_000);
afterAll(async () => { vi.unstubAllEnvs(); await temp?.cleanup(); });
it("projects mixed review verdicts into history instead of generic answers", async () => {
const companyId = randomUUID(), issueId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Review history", issuePrefix: "VRD" });
await db.insert(issues).values({ id: issueId, companyId, title: "Reviewed outputs" });
await db.insert(issueThreadInteractions).values({ companyId, issueId, kind: "request_item_verdicts", status: "answered",
resolvedAt: new Date(), resolvedByUserId: "reviewer", payload: { version: 1, prompt: "Review outputs", items: [] },
result: { version: 1, outcome: "resolved", complete: true, items: [
{ id: "copy", verdict: "approve", resolvedAt: new Date().toISOString() },
{ id: "art", verdict: "reject", resolvedAt: new Date().toISOString() },
{ id: "extra", verdict: "defer", resolvedAt: new Date().toISOString() },
] } });
const page = await decisionHistoryService(db).list(companyId, { actor: { type: "board", userId: "reviewer" }, userId: "reviewer" });
expect(page.items[0]?.label).toBe("1 approved · 1 rejected · 1 deferred");
});
it("returns a cross-task document receipt whose exact revision belongs to the target, not the origin", async () => {
const companyId = randomUUID(), originId = randomUUID(), documentOwnerId = randomUUID(), interactionId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Cross-task review", issuePrefix: "XDR" });
+14 -3
View File
@@ -7,6 +7,17 @@ import { badRequest } from "../errors.js";
type HistoryOptions = DecisionHistoryQuery & { actor: AuthorizationActor; userId: string; issueIds?: string[] };
type Cursor = { at: string; id: string; sort: "newest" | "oldest" };
export function itemVerdictsHistoryLabel(result: unknown): string | null {
if (!result || typeof result !== "object" || !("outcome" in result) || result.outcome !== "resolved"
|| !("items" in result) || !Array.isArray(result.items)) return null;
const counts = { approve: 0, reject: 0, defer: 0 };
for (const item of result.items) {
if (item?.verdict === "approve" || item?.verdict === "reject" || item?.verdict === "defer") counts[item.verdict as keyof typeof counts]++;
}
return [counts.approve ? `${counts.approve} approved` : null, counts.reject ? `${counts.reject} rejected` : null,
counts.defer ? `${counts.defer} deferred` : null].filter(Boolean).join(" · ") || "Review recorded";
}
export function decodeDecisionHistoryCursor(value: string | undefined, sort: Cursor["sort"]): Cursor | null {
if (!value) return null;
try {
@@ -84,7 +95,7 @@ export function decisionHistoryService(db: Db) {
agentId: agents.id, agentName: agents.name, projectId: projects.id, projectName: projects.name,
title: issueThreadInteractions.title, outcome: issueThreadInteractions.status, resolvedAt: issueThreadInteractions.resolvedAt,
resolvedByUserId: issueThreadInteractions.resolvedByUserId, resolvedByAgentId: issueThreadInteractions.resolvedByAgentId,
payload: issueThreadInteractions.payload, result: issueThreadInteractions.result })
kind: issueThreadInteractions.kind, payload: issueThreadInteractions.payload, result: issueThreadInteractions.result })
.from(issueThreadInteractions).innerJoin(issues, and(eq(issues.id, issueThreadInteractions.issueId), eq(issues.companyId, companyId)))
.leftJoin(agents, and(eq(agents.id, issueThreadInteractions.createdByAgentId), eq(agents.companyId, companyId)))
.leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, companyId)))
@@ -108,12 +119,12 @@ export function decisionHistoryService(db: Db) {
...conditions(decisionTime, decisionKey, decisions.title)))
.orderBy(order(decisionTime), order(decisionKey)).limit(limit + 1),
]);
const interactionItems: DecisionHistoryItem[] = interactionRows.map(({ payload, result, ...row }) => {
const interactionItems: DecisionHistoryItem[] = interactionRows.map(({ kind, payload, result, ...row }) => {
const target = decisionHistoryTarget(payload);
const resultOutcome = result && "outcome" in result ? String(result.outcome) : row.outcome;
const labels: Record<string, string> = { accepted: "Approved", rejected: "Changes requested", answered: "Answers recorded", cancelled: "Cancelled", expired: "Expired", failed: "Failed", stale_target: "Superseded", superseded_by_newer_request: "Superseded", superseded_by_comment: "Answered in task" };
return { ...row, title: row.title ?? row.issueTitle, resolvedAt: new Date(row.resolvedAt!).toISOString(),
id: `issue_thread_interaction:${row.sourceId}`, sourceKind: "issue_thread_interaction", label: labels[resultOutcome] ?? labels[row.outcome] ?? "Decision recorded", target,
id: `issue_thread_interaction:${row.sourceId}`, sourceKind: "issue_thread_interaction", label: (kind === "request_item_verdicts" ? itemVerdictsHistoryLabel(result) : null) ?? labels[resultOutcome] ?? labels[row.outcome] ?? "Decision recorded", target,
href: decisionHistoryHref(company.prefix, row.issueIdentifier ?? row.issueId, "issue_thread_interaction", row.sourceId, target) };
});
const decisionItems: DecisionHistoryItem[] = decisionRows.map(({ chosenOptionId, options: choices, ...row }) => ({
+4 -1
View File
@@ -1,4 +1,4 @@
import { projectDiscoverySchema, createProjectSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared";
import { projectDiscoverySchema, createProjectSchema, createIssueSchema, setIssueTitleSchema, requiredQualityReviewSchema } from "@paperclipai/shared";
import { z } from "zod";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../vendor/paperclip-runner/index.js";
import { badRequest } from "../errors.js";
@@ -42,6 +42,9 @@ export async function callProjectTool(input: {
body = createIssueSchema.parse({
title: args.title, description: args.description, priority: args.priority,
projectId: args.projectId, initialPlan: args.initialPlan,
...(args.requiredQualityReview !== undefined ? {
executionPolicy: { requiredQualityReview: requiredQualityReviewSchema.parse(args.requiredQualityReview) },
} : {}),
assigneeAgentId: args.assigneeActorId ?? (args.assigneeUserId ? null : input.agentId),
assigneeUserId: args.assigneeUserId,
parentId: input.conversation ? null : input.issueId,