mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
fix: preserve review requirements, output links, and decision outcomes
This commit is contained in:
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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user