feat(artifacts): bridge Markdown work products into the document review surface (#11822)

This commit is contained in:
Michael Nguyen 2026-08-20 14:28:01 -10:00 committed by GitHub
parent 733ffbf7c3
commit 0fa318b8da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2121 additions and 35 deletions

View File

@ -84,6 +84,19 @@ export {
type ResponsibleUserSource,
type OriginatingActor,
} from "./issue-attribution.js";
export {
ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX,
MARKDOWN_ATTACHMENT_CONTENT_TYPES,
MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES,
artifactReviewDocumentKey,
getAttachmentArtifactWorkProductMetadata,
getMarkdownWorkProductAttachmentMetadata,
isArtifactReviewDocumentKey,
isMarkdownArtifactWorkProduct,
isMarkdownAttachmentContent,
workProductIdFromArtifactReviewDocumentKey,
type AttachmentArtifactWorkProductLike,
} from "./markdown-work-products.js";
export {
ISSUE_WRITE_DENIAL_CODES,
describeIssueWriteDenial,

View File

@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import {
ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX,
artifactReviewDocumentKey,
getAttachmentArtifactWorkProductMetadata,
getMarkdownWorkProductAttachmentMetadata,
isArtifactReviewDocumentKey,
isMarkdownArtifactWorkProduct,
isMarkdownAttachmentContent,
workProductIdFromArtifactReviewDocumentKey,
} from "./markdown-work-products.js";
const ATTACHMENT_ID = "00000000-0000-4000-8000-000000000001";
const WORK_PRODUCT_ID = "11111111-2222-4333-8444-555555555555";
function attachmentMetadata(overrides: Record<string, unknown> = {}) {
const contentPath = `/api/attachments/${ATTACHMENT_ID}/content`;
return {
attachmentId: ATTACHMENT_ID,
contentType: "text/markdown",
byteSize: 128,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
originalFilename: "notes.md",
...overrides,
};
}
function workProduct(overrides: Record<string, unknown> = {}) {
return {
type: "artifact" as const,
provider: "paperclip",
metadata: attachmentMetadata(),
...overrides,
};
}
describe("isMarkdownAttachmentContent", () => {
it.each([
"text/markdown",
"text/x-markdown",
"application/markdown",
"application/x-markdown",
])("accepts %s regardless of filename", (contentType) => {
expect(isMarkdownAttachmentContent({ contentType, originalFilename: null })).toBe(true);
});
it("ignores MIME parameters and case", () => {
expect(
isMarkdownAttachmentContent({
contentType: "Text/Markdown; charset=utf-8",
originalFilename: null,
}),
).toBe(true);
});
it.each([
"text/plain",
"application/octet-stream",
"binary/octet-stream",
"application/x-binary",
])("accepts a .md filename when the content type is %s", (contentType) => {
expect(isMarkdownAttachmentContent({ contentType, originalFilename: "README.md" })).toBe(true);
});
it("accepts a .markdown filename with a generic content type", () => {
expect(
isMarkdownAttachmentContent({
contentType: "application/octet-stream",
originalFilename: "guide.markdown",
}),
).toBe(true);
});
it("rejects .mdx files", () => {
expect(
isMarkdownAttachmentContent({
contentType: "application/octet-stream",
originalFilename: "page.mdx",
}),
).toBe(false);
expect(
isMarkdownAttachmentContent({ contentType: "text/plain", originalFilename: "page.mdx" }),
).toBe(false);
});
it("rejects markdown filenames with non-generic content types", () => {
expect(
isMarkdownAttachmentContent({ contentType: "text/html", originalFilename: "notes.md" }),
).toBe(false);
});
it("rejects non-markdown filenames with generic content types", () => {
expect(
isMarkdownAttachmentContent({ contentType: "text/plain", originalFilename: "notes.txt" }),
).toBe(false);
});
it("rejects a missing content type", () => {
expect(isMarkdownAttachmentContent({ contentType: null, originalFilename: "notes.md" })).toBe(
false,
);
});
});
describe("getAttachmentArtifactWorkProductMetadata", () => {
it("returns canonical metadata for a valid paperclip artifact work product", () => {
const metadata = getAttachmentArtifactWorkProductMetadata(workProduct());
expect(metadata?.attachmentId).toBe(ATTACHMENT_ID);
});
it("returns null for non-artifact work products", () => {
expect(getAttachmentArtifactWorkProductMetadata(workProduct({ type: "document" }))).toBeNull();
});
it("returns null for non-paperclip providers", () => {
expect(getAttachmentArtifactWorkProductMetadata(workProduct({ provider: "github" }))).toBeNull();
});
it("returns null when metadata is missing or non-canonical", () => {
expect(getAttachmentArtifactWorkProductMetadata(workProduct({ metadata: null }))).toBeNull();
expect(
getAttachmentArtifactWorkProductMetadata(
workProduct({
metadata: attachmentMetadata({ openPath: "https://evil.example/content" }),
}),
),
).toBeNull();
});
});
describe("getMarkdownWorkProductAttachmentMetadata", () => {
it("returns metadata only when the attachment is markdown-eligible", () => {
expect(getMarkdownWorkProductAttachmentMetadata(workProduct())).not.toBeNull();
expect(
getMarkdownWorkProductAttachmentMetadata(
workProduct({
metadata: attachmentMetadata({ contentType: "application/pdf", originalFilename: "a.pdf" }),
}),
),
).toBeNull();
});
it("backs the boolean eligibility helper", () => {
expect(isMarkdownArtifactWorkProduct(workProduct())).toBe(true);
expect(
isMarkdownArtifactWorkProduct(
workProduct({
metadata: attachmentMetadata({ contentType: "image/png", originalFilename: "a.png" }),
}),
),
).toBe(false);
});
});
describe("artifact review document keys", () => {
it("builds a deterministic key that fits the 64-character document key limit", () => {
const key = artifactReviewDocumentKey(WORK_PRODUCT_ID);
expect(key).toBe(`artifact-review-${WORK_PRODUCT_ID}`);
expect(key.length).toBeLessThanOrEqual(64);
expect(key).toMatch(/^[a-z0-9][a-z0-9_-]*$/);
});
it("identifies proxy review-document keys", () => {
expect(isArtifactReviewDocumentKey(artifactReviewDocumentKey(WORK_PRODUCT_ID))).toBe(true);
expect(isArtifactReviewDocumentKey("plan")).toBe(false);
expect(isArtifactReviewDocumentKey("notes")).toBe(false);
expect(isArtifactReviewDocumentKey(ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX)).toBe(false);
});
it("round-trips the work product id", () => {
expect(
workProductIdFromArtifactReviewDocumentKey(artifactReviewDocumentKey(WORK_PRODUCT_ID)),
).toBe(WORK_PRODUCT_ID);
expect(workProductIdFromArtifactReviewDocumentKey("plan")).toBeNull();
});
});

View File

@ -0,0 +1,99 @@
import type {
AttachmentArtifactWorkProductMetadata,
IssueWorkProduct,
} from "./types/work-product.js";
import { attachmentArtifactWorkProductMetadataSchema } from "./validators/work-product.js";
/**
* Shared classification for bridging attachment-backed Markdown work products
* into the issue document review experience. Server materialization and UI
* presentation must agree on eligibility, so both sides use these helpers.
*/
export const MARKDOWN_ATTACHMENT_CONTENT_TYPES = [
"text/markdown",
"text/x-markdown",
"application/markdown",
"application/x-markdown",
] as const;
const MARKDOWN_ATTACHMENT_CONTENT_TYPE_SET = new Set<string>(MARKDOWN_ATTACHMENT_CONTENT_TYPES);
const MARKDOWN_FALLBACK_CONTENT_TYPES = new Set([
"text/plain",
"application/octet-stream",
"binary/octet-stream",
"application/x-binary",
]);
const MARKDOWN_FILENAME_EXTENSIONS = [".md", ".markdown"];
/**
* Maximum attachment size eligible for review-document materialization.
* Matches the issue-document body limit: a UTF-8 payload of at most this many
* bytes always fits the 524288-character body cap.
*/
export const MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES = 512 * 1024;
export const ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX = "artifact-review-";
function normalizeAttachmentContentType(contentType: string | null | undefined): string {
return (contentType ?? "").toLowerCase().split(";")[0]?.trim() ?? "";
}
export function isMarkdownAttachmentContent(input: {
contentType: string | null | undefined;
originalFilename?: string | null;
}): boolean {
const contentType = normalizeAttachmentContentType(input.contentType);
if (MARKDOWN_ATTACHMENT_CONTENT_TYPE_SET.has(contentType)) return true;
const filename = (input.originalFilename ?? "").toLowerCase();
if (!MARKDOWN_FILENAME_EXTENSIONS.some((extension) => filename.endsWith(extension))) {
return false;
}
return MARKDOWN_FALLBACK_CONTENT_TYPES.has(contentType);
}
export type AttachmentArtifactWorkProductLike = Pick<
IssueWorkProduct,
"type" | "provider" | "metadata"
>;
export function getAttachmentArtifactWorkProductMetadata(
workProduct: AttachmentArtifactWorkProductLike,
): AttachmentArtifactWorkProductMetadata | null {
if (workProduct.type !== "artifact" || workProduct.provider !== "paperclip") return null;
if (!workProduct.metadata) return null;
const parsed = attachmentArtifactWorkProductMetadataSchema.safeParse(workProduct.metadata);
return parsed.success ? parsed.data : null;
}
export function getMarkdownWorkProductAttachmentMetadata(
workProduct: AttachmentArtifactWorkProductLike,
): AttachmentArtifactWorkProductMetadata | null {
const metadata = getAttachmentArtifactWorkProductMetadata(workProduct);
if (!metadata) return null;
return isMarkdownAttachmentContent(metadata) ? metadata : null;
}
export function isMarkdownArtifactWorkProduct(
workProduct: AttachmentArtifactWorkProductLike,
): boolean {
return getMarkdownWorkProductAttachmentMetadata(workProduct) !== null;
}
export function artifactReviewDocumentKey(workProductId: string): string {
return `${ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX}${workProductId}`;
}
export function isArtifactReviewDocumentKey(key: string): boolean {
return (
key.startsWith(ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX) &&
key.length > ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX.length
);
}
export function workProductIdFromArtifactReviewDocumentKey(key: string): string | null {
if (!isArtifactReviewDocumentKey(key)) return null;
return key.slice(ARTIFACT_REVIEW_DOCUMENT_KEY_PREFIX.length);
}

View File

@ -0,0 +1,334 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StorageService } from "../storage/types.js";
const ISSUE_ID = "11111111-1111-4111-8111-111111111111";
const WORK_PRODUCT_ID = "22222222-2222-4222-8222-222222222222";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
getAttachmentById: vi.fn(),
}));
const mockCompanyService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockWorkProductService = vi.hoisted(() => ({
createForIssue: vi.fn(),
getById: vi.fn(),
update: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(async () => ({
allowed: true,
explanation: "Allowed by test mock",
})),
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockEnsureForWorkProduct = vi.hoisted(() => vi.fn());
const mockSyncDocumentSafely = vi.hoisted(() => vi.fn(async () => undefined));
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/artifact-review-documents.js", () => ({
artifactReviewDocumentService: () => ({
ensureForWorkProduct: mockEnsureForWorkProduct,
}),
}));
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => ({
syncDocumentSafely: mockSyncDocumentSafely,
}),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({
getById: vi.fn(),
}),
companySkillService: () => ({}),
companyService: () => mockCompanyService,
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => mockWorkProductService,
}));
}
function createStorageService(): StorageService {
return {
provider: "local_disk",
putFile: vi.fn(),
getObject: vi.fn(),
headObject: vi.fn(),
deleteObject: vi.fn(),
} as unknown as StorageService;
}
async function createApp(options?: { companyIds?: string[] }) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: options?.companyIds ?? ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, createStorageService()));
app.use(errorHandler);
return app;
}
function makeIssue() {
return {
id: ISSUE_ID,
companyId: "company-1",
identifier: "PAP-1",
projectId: null,
status: "in_progress",
assigneeAgentId: null,
};
}
function makeWorkProduct(overrides: Record<string, unknown> = {}) {
const attachmentId = "55555555-5555-4555-8555-555555555555";
const contentPath = `/api/attachments/${attachmentId}/content`;
return {
id: WORK_PRODUCT_ID,
companyId: "company-1",
issueId: ISSUE_ID,
type: "artifact",
provider: "paperclip",
title: "Verification report",
metadata: {
attachmentId,
contentType: "text/markdown",
byteSize: 128,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
originalFilename: "verification-report.md",
},
createdByRunId: null,
sourceTrust: null,
...overrides,
};
}
function makeDocument(overrides: Record<string, unknown> = {}) {
return {
id: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
issueId: ISSUE_ID,
key: `artifact-review-${WORK_PRODUCT_ID}`,
title: "Verification report",
format: "markdown",
body: "# Report",
latestRevisionId: "44444444-4444-4444-8444-444444444444",
latestRevisionNumber: 1,
...overrides,
};
}
describe("work product review-document route", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
registerRouteMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockWorkProductService.getById.mockResolvedValue(makeWorkProduct());
});
it("returns 404 when the work product belongs to another issue", async () => {
mockWorkProductService.getById.mockResolvedValue(
makeWorkProduct({ issueId: "55555555-5555-4555-8555-555555555555" }),
);
const app = await createApp();
const res = await request(app).post(
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
);
expect(res.status).toBe(404);
expect(mockEnsureForWorkProduct).not.toHaveBeenCalled();
});
it("returns 404 when the work product does not exist", async () => {
mockWorkProductService.getById.mockResolvedValue(null);
const app = await createApp();
const res = await request(app).post(
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
);
expect(res.status).toBe(404);
expect(mockEnsureForWorkProduct).not.toHaveBeenCalled();
});
it("materializes the review document and logs document creation", async () => {
mockEnsureForWorkProduct.mockResolvedValue({
document: makeDocument(),
created: true,
revisionChanged: true,
remappedAnnotations: [],
});
const app = await createApp();
const res = await request(app).post(
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
);
expect(res.status).toBe(201);
expect(res.body.key).toBe(`artifact-review-${WORK_PRODUCT_ID}`);
expect(mockEnsureForWorkProduct).toHaveBeenCalledWith({
issue: { id: ISSUE_ID, companyId: "company-1" },
workProduct: expect.objectContaining({ id: WORK_PRODUCT_ID }),
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.document_created",
details: expect.objectContaining({
workProductId: WORK_PRODUCT_ID,
artifactReviewDocument: true,
}),
}),
);
expect(mockSyncDocumentSafely).toHaveBeenCalledWith(makeDocument().id);
});
it("returns 200 without side effects when the document is already current", async () => {
mockEnsureForWorkProduct.mockResolvedValue({
document: makeDocument(),
created: false,
revisionChanged: false,
remappedAnnotations: [],
});
const app = await createApp();
const res = await request(app).post(
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
);
expect(res.status).toBe(200);
expect(res.body.id).toBe(makeDocument().id);
expect(mockLogActivity).not.toHaveBeenCalled();
expect(mockSyncDocumentSafely).not.toHaveBeenCalled();
});
it("passes typed materialization errors through to the response", async () => {
const { HttpError } = await vi.importActual<typeof import("../errors.js")>("../errors.js");
mockEnsureForWorkProduct.mockRejectedValue(
new HttpError(415, "Work product attachment is not Markdown"),
);
const app = await createApp();
const res = await request(app).post(
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
);
expect(res.status).toBe(415);
expect(res.body.error).toBe("Work product attachment is not Markdown");
});
it("resynchronizes an eligible review document after a title-only work product update", async () => {
const renamed = makeWorkProduct({ title: "Renamed verification report" });
mockWorkProductService.update.mockResolvedValue(renamed);
mockEnsureForWorkProduct.mockResolvedValue({
document: makeDocument({ title: "Renamed verification report", latestRevisionNumber: 2 }),
created: false,
revisionChanged: true,
remappedAnnotations: [],
});
const app = await createApp();
const res = await request(app)
.patch(`/api/work-products/${WORK_PRODUCT_ID}`)
.send({ title: "Renamed verification report" });
expect(res.status).toBe(200);
expect(mockEnsureForWorkProduct).toHaveBeenCalledWith({
issue: { id: ISSUE_ID, companyId: "company-1" },
workProduct: renamed,
});
});
});

View File

@ -0,0 +1,403 @@
import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
assets,
companies,
createDb,
documentRevisions,
documents,
heartbeatRuns,
issueAttachments,
issueDocuments,
issues,
} from "@paperclipai/db";
import { artifactReviewDocumentKey } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { artifactReviewDocumentService } from "../services/artifact-review-documents.js";
import { documentService } from "../services/documents.js";
import { HttpError } from "../errors.js";
import type { StorageService } from "../storage/types.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres artifact review document tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function createStorageService(files: Record<string, Buffer> = {}): StorageService {
return {
provider: "local_disk",
putFile: vi.fn(),
getObject: vi.fn(async (_companyId, objectKey, options) => {
const body = files[objectKey] ?? Buffer.alloc(0);
const range = options?.range;
const ranged = range ? body.subarray(range.start, range.end + 1) : body;
return {
stream: Readable.from(ranged),
contentType: "text/markdown",
contentLength: ranged.length,
};
}),
headObject: vi.fn(),
deleteObject: vi.fn(),
};
}
describeEmbeddedPostgres("artifactReviewDocumentService", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db!: ReturnType<typeof createDb>;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-artifact-review-docs-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(documentRevisions);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issueAttachments);
await db.delete(assets);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedFixture(options: {
body?: Buffer;
contentType?: string;
originalFilename?: string | null;
byteSize?: number;
} = {}) {
const companyId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const assetId = randomUUID();
const attachmentId = randomUUID();
const workProductId = randomUUID();
const body = options.body ?? Buffer.from("# Report\n\nHello **markdown**.\n", "utf8");
const contentType = options.contentType ?? "text/markdown";
const originalFilename = options.originalFilename === undefined ? "report.md" : options.originalFilename;
const objectKey = `issues/${issueId}/${assetId}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "PAP-1534",
title: "Bridge markdown work products",
description: null,
status: "in_progress",
priority: "medium",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "ReportWriter",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(assets).values({
id: assetId,
companyId,
provider: "local_disk",
objectKey,
contentType,
byteSize: options.byteSize ?? body.length,
sha256: `sha256-${assetId}`,
originalFilename,
createdByAgentId: agentId,
});
await db.insert(issueAttachments).values({
id: attachmentId,
companyId,
issueId,
assetId,
});
const contentPath = `/api/attachments/${attachmentId}/content`;
const workProduct = {
id: workProductId,
companyId,
issueId,
type: "artifact" as const,
provider: "paperclip",
metadata: {
attachmentId,
contentType,
byteSize: options.byteSize ?? body.length,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
originalFilename,
},
title: "Verification report",
createdByRunId: null,
sourceTrust: null,
};
const files: Record<string, Buffer> = { [objectKey]: body };
const storage = createStorageService(files);
const svc = artifactReviewDocumentService(db, storage);
return { companyId, issueId, agentId, assetId, attachmentId, workProduct, files, objectKey, svc };
}
it("materializes a review document with promoted attribution", async () => {
const fixture = await seedFixture();
const result = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
});
expect(result.created).toBe(true);
expect(result.revisionChanged).toBe(true);
expect(result.document.key).toBe(artifactReviewDocumentKey(fixture.workProduct.id));
expect(result.document.title).toBe("Verification report");
expect(result.document.latestRevisionNumber).toBe(1);
expect(result.document.latestRevisionId).toBeTruthy();
expect(result.document.body).toBe("# Report\n\nHello **markdown**.\n");
expect(result.document.createdByAgentId).toBe(fixture.agentId);
expect(result.document.createdByUserId).toBeNull();
const listed = await documentService(db).getIssueDocumentByKey(
fixture.issueId,
artifactReviewDocumentKey(fixture.workProduct.id),
);
expect(listed?.id).toBe(result.document.id);
});
it("is idempotent for unchanged content", async () => {
const fixture = await seedFixture();
const first = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
});
const second = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
});
expect(second.created).toBe(false);
expect(second.revisionChanged).toBe(false);
expect(second.document.id).toBe(first.document.id);
expect(second.document.latestRevisionNumber).toBe(1);
const revisions = await db.select().from(documentRevisions);
expect(revisions).toHaveLength(1);
});
it("converges concurrent ensures onto one document", async () => {
const fixture = await seedFixture();
const input = {
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
};
const [first, second] = await Promise.all([
fixture.svc.ensureForWorkProduct(input),
fixture.svc.ensureForWorkProduct(input),
]);
expect(first.document.id).toBe(second.document.id);
const links = await db.select().from(issueDocuments);
expect(links).toHaveLength(1);
const revisions = await db.select().from(documentRevisions);
expect(revisions).toHaveLength(1);
});
it("writes a new revision when the attachment content changes", async () => {
const fixture = await seedFixture();
const first = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
});
fixture.files[fixture.objectKey] = Buffer.from("# Report v2\n", "utf8");
const second = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
});
expect(second.created).toBe(false);
expect(second.revisionChanged).toBe(true);
expect(second.document.id).toBe(first.document.id);
expect(second.document.latestRevisionNumber).toBe(2);
expect(second.document.body).toBe("# Report v2\n");
expect(Array.isArray(second.remappedAnnotations)).toBe(true);
});
it("synchronizes title and run provenance when the attachment body is unchanged", async () => {
const fixture = await seedFixture();
const first = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
});
const renamed = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: { ...fixture.workProduct, title: "Renamed verification report" },
});
expect(renamed.created).toBe(false);
expect(renamed.revisionChanged).toBe(true);
expect(renamed.document.id).toBe(first.document.id);
expect(renamed.document.title).toBe("Renamed verification report");
expect(renamed.document.latestRevisionNumber).toBe(2);
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId: fixture.companyId,
agentId: fixture.agentId,
status: "succeeded",
});
const reattributed = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: {
...fixture.workProduct,
title: "Renamed verification report",
createdByRunId: runId,
},
});
expect(reattributed.revisionChanged).toBe(true);
expect(reattributed.document.latestRevisionNumber).toBe(3);
const latestRevision = await db
.select()
.from(documentRevisions)
.then((rows) => rows.find((row) => row.id === reattributed.document.latestRevisionId));
expect(latestRevision?.createdByRunId).toBe(runId);
const unchanged = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: {
...fixture.workProduct,
title: "Renamed verification report",
createdByRunId: runId,
},
});
expect(unchanged.revisionChanged).toBe(false);
expect(unchanged.document.latestRevisionNumber).toBe(3);
});
it("rejects work products that are not attachment-backed Paperclip artifacts", async () => {
const fixture = await seedFixture();
await expect(
fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: { ...fixture.workProduct, provider: "github" },
}),
).rejects.toMatchObject({ status: 422 });
await expect(
fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: { ...fixture.workProduct, metadata: { attachmentId: "not-a-uuid" } },
}),
).rejects.toMatchObject({ status: 422 });
});
it("rejects non-Markdown attachments with 415", async () => {
const fixture = await seedFixture({ contentType: "application/pdf", originalFilename: "report.pdf" });
await expect(
fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
}),
).rejects.toMatchObject({ status: 415 });
});
it("rejects mismatched issues and cross-issue attachments", async () => {
const fixture = await seedFixture();
await expect(
fixture.svc.ensureForWorkProduct({
issue: { id: randomUUID(), companyId: fixture.companyId },
workProduct: fixture.workProduct,
}),
).rejects.toMatchObject({ status: 404 });
const otherIssueId = randomUUID();
await db.insert(issues).values({
id: otherIssueId,
companyId: fixture.companyId,
identifier: "PAP-1535",
title: "Other issue",
description: null,
status: "todo",
priority: "medium",
});
await expect(
fixture.svc.ensureForWorkProduct({
issue: { id: otherIssueId, companyId: fixture.companyId },
workProduct: { ...fixture.workProduct, issueId: otherIssueId },
}),
).rejects.toMatchObject({ status: 422 });
});
it("rejects oversized attachments with 413", async () => {
const fixture = await seedFixture({ byteSize: 512 * 1024 + 1 });
await expect(
fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
}),
).rejects.toMatchObject({ status: 413 });
});
it("rejects invalid UTF-8 content with 422", async () => {
const fixture = await seedFixture({ body: Buffer.from([0x23, 0x20, 0xff, 0xfe, 0x00]) });
await expect(
fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
}),
).rejects.toMatchObject({ status: 422 });
});
it("surfaces a conflict when the review document is locked and content changed", async () => {
const fixture = await seedFixture();
await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
});
await documentService(db).lockIssueDocument({
issueId: fixture.issueId,
key: artifactReviewDocumentKey(fixture.workProduct.id),
lockedByUserId: "user-board",
});
// Unchanged content still resolves without a write.
const unchanged = await fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
});
expect(unchanged.revisionChanged).toBe(false);
fixture.files[fixture.objectKey] = Buffer.from("# Locked update\n", "utf8");
await expect(
fixture.svc.ensureForWorkProduct({
issue: { id: fixture.issueId, companyId: fixture.companyId },
workProduct: fixture.workProduct,
}),
).rejects.toSatisfy((error: unknown) => error instanceof HttpError && error.status === 409);
});
});

View File

@ -515,6 +515,36 @@ describe("issue attachment routes", () => {
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});
it("declares utf-8 for inline markdown attachments", async () => {
const storage = createStorageService(Buffer.from("# Hello\n"));
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("text/markdown", "notes.md"),
byteSize: 8,
});
const app = await createApp(storage);
const res = await request(app).get("/api/attachments/attachment-1/content");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("text/markdown; charset=utf-8");
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});
it("keeps the charset declaration on forced markdown downloads", async () => {
const storage = createStorageService(Buffer.from("# Hello\n"));
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("text/markdown", "notes.md"),
byteSize: 8,
});
const app = await createApp(storage);
const res = await request(app).get("/api/attachments/attachment-1/content?download=1");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("text/markdown; charset=utf-8");
expect(res.headers["content-disposition"]).toBe('attachment; filename="notes.md"');
});
it("keeps image attachments inline for previews", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("image/png", "preview.png"));

View File

@ -210,6 +210,11 @@ describe("openapi routes", () => {
},
});
expect(res.body.paths["/api/companies/{companyId}/folders"].post.responses["201"]).toBeDefined();
expect(
Object.keys(
res.body.paths["/api/issues/{id}/work-products/{workProductId}/review-document"].post.responses,
).sort(),
).toEqual(["200", "201", "401", "403", "404", "409", "413", "415", "422"]);
expect(
res.body.paths["/api/issues/{id}/interactions/{interactionId}/withdraw"].post.summary,
).toBe("Withdraw a pending issue thread interaction");

View File

@ -29,6 +29,14 @@ export function conflict(message: string, details?: unknown) {
return new HttpError(409, message, details);
}
export function payloadTooLarge(message: string, details?: unknown) {
return new HttpError(413, message, details);
}
export function unsupportedMediaType(message: string, details?: unknown) {
return new HttpError(415, message, details);
}
export function unprocessable(message: string, details?: unknown) {
return new HttpError(422, message, details);
}

View File

@ -67,6 +67,8 @@ import {
upsertIssueDocumentSchema,
updateIssueSchema,
isClosedIsolatedExecutionWorkspace,
isMarkdownArtifactWorkProduct,
isMarkdownAttachmentContent,
isUuidLike,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
type CompactIssue,
@ -138,6 +140,7 @@ import {
routineService,
workProductService,
} from "../services/index.js";
import { artifactReviewDocumentService } from "../services/artifact-review-documents.js";
import { assertCanResolveProposal } from "../services/secret-proposal-authorization.js";
import { buildDocumentReviewContext, buildPlanReviewContext } from "../services/plan-review-context.js";
import {
@ -2830,6 +2833,7 @@ export function issueRoutes(
const executionWorkspacesSvc = executionWorkspaceServiceDirect(db);
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
const artifactReviewDocumentsSvc = artifactReviewDocumentService(db, storage);
const companySkillsSvc = companySkillService(db);
const documentAnnotationsSvc = documentAnnotationService(db);
const decisionTrainingSvc = decisionTrainingService(db);
@ -7888,9 +7892,110 @@ export function issueRoutes(
actor,
workProductChanged: true,
});
await materializeArtifactReviewDocumentBestEffort({ issue, workProduct: product, actor });
res.status(201).json(product);
});
async function ensureArtifactReviewDocumentForWorkProduct(input: {
issue: NonNullable<Awaited<ReturnType<typeof svc.getById>>>;
workProduct: NonNullable<Awaited<ReturnType<typeof workProductsSvc.getById>>>;
actor: ReturnType<typeof getActorInfo>;
}) {
const { issue, workProduct, actor } = input;
const result = await artifactReviewDocumentsSvc.ensureForWorkProduct({
issue: { id: issue.id, companyId: issue.companyId },
workProduct,
});
if (!result.revisionChanged) return result;
const doc = result.document;
await issueReferencesSvc.syncDocument(doc.id);
await externalObjectsSvc.syncDocumentSafely(doc.id);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: result.created ? "issue.document_created" : "issue.document_updated",
entityType: "issue",
entityId: issue.id,
details: {
key: doc.key,
documentId: doc.id,
title: doc.title,
format: doc.format,
revisionNumber: doc.latestRevisionNumber,
workProductId: workProduct.id,
artifactReviewDocument: true,
},
});
for (const remap of result.remappedAnnotations) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.document_annotation_remapped",
entityType: "issue",
entityId: issue.id,
details: {
key: doc.key,
documentId: doc.id,
threadId: remap.thread.id,
revisionNumber: doc.latestRevisionNumber,
anchorState: remap.thread.anchorState,
anchorConfidence: remap.thread.anchorConfidence,
snapshotId: remap.snapshot.id,
},
});
}
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "document",
actor,
documentChanged: true,
});
return result;
}
async function materializeArtifactReviewDocumentBestEffort(input: {
issue: NonNullable<Awaited<ReturnType<typeof svc.getById>>>;
workProduct: NonNullable<Awaited<ReturnType<typeof workProductsSvc.getById>>>;
actor: ReturnType<typeof getActorInfo>;
}) {
if (!isMarkdownArtifactWorkProduct(input.workProduct)) return;
try {
await ensureArtifactReviewDocumentForWorkProduct(input);
} catch (error) {
// Work-product writes stay fail-open: raw open and download remain
// available, and the explicit review-document endpoint is the retry path.
logger.warn(
{ err: error, issueId: input.issue.id, workProductId: input.workProduct.id },
"markdown work product review-document materialization failed",
);
}
}
router.post("/issues/:id/work-products/:workProductId/review-document", async (req, res) => {
const id = req.params.id as string;
const workProductId = req.params.workProductId as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
const workProduct = await workProductsSvc.getById(workProductId);
if (!workProduct || workProduct.issueId !== issue.id || workProduct.companyId !== issue.companyId) {
res.status(404).json({ error: "Work product not found" });
return;
}
const actor = getActorInfo(req);
const result = await ensureArtifactReviewDocumentForWorkProduct({ issue, workProduct, actor });
res.status(result.created ? 201 : 200).json(result.document);
});
router.post("/issues/:id/low-trust/promotions", validate(promoteLowTrustOutputSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
@ -8087,6 +8192,11 @@ export function issueRoutes(
actor,
workProductChanged: true,
});
const reviewDocumentInputChanged = ["type", "provider", "metadata", "title", "createdByRunId"]
.some((key) => Object.prototype.hasOwnProperty.call(patch, key));
if (reviewDocumentInputChanged || sourceTrust) {
await materializeArtifactReviewDocumentBestEffort({ issue, workProduct: product, actor });
}
res.json(product);
});
@ -12997,7 +13107,16 @@ export function issueRoutes(
objectContentType: object.contentType,
originalFilename: attachment.originalFilename,
});
res.setHeader("Content-Type", responseContentType);
// Markdown bodies are stored as UTF-8; declare the charset so inline
// (raw) views do not mojibake. SVG/inline checks below stay on the bare type.
const isMarkdownResponse = isMarkdownAttachmentContent({
contentType: responseContentType,
originalFilename: attachment.originalFilename,
});
res.setHeader(
"Content-Type",
isMarkdownResponse ? `${responseContentType}; charset=utf-8` : responseContentType,
);
res.setHeader("Cache-Control", "private, max-age=60");
res.setHeader("X-Content-Type-Options", "nosniff");
if (responseContentType === SVG_CONTENT_TYPE) {

View File

@ -536,6 +536,14 @@ const responses = {
description: "Conflict",
content: { "application/json": { schema: ErrorSchema } },
},
payloadTooLarge: {
description: "Payload too large",
content: { "application/json": { schema: ErrorSchema } },
},
unsupportedMediaType: {
description: "Unsupported media type",
content: { "application/json": { schema: ErrorSchema } },
},
unprocessable: {
description: "Unprocessable entity",
content: { "application/json": { schema: ErrorSchema } },
@ -2318,6 +2326,25 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
registry.registerPath({
method: "post",
path: "/api/issues/{id}/work-products/{workProductId}/review-document",
tags: ["issues"],
summary: "Ensure the review document for a Markdown work product",
request: { params: z.object({ id: z.string(), workProductId: z.string() }) },
responses: {
200: r.ok(),
201: r.ok(),
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
413: r.payloadTooLarge,
415: r.unsupportedMediaType,
422: r.unprocessable,
},
});
registry.registerPath({
method: "patch",
path: "/api/work-products/{id}",

View File

@ -0,0 +1,245 @@
import { isDeepStrictEqual } from "node:util";
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { assets, documentRevisions, documents, issueAttachments, issueDocuments } from "@paperclipai/db";
import {
MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES,
artifactReviewDocumentKey,
getAttachmentArtifactWorkProductMetadata,
isMarkdownAttachmentContent,
type AttachmentArtifactWorkProductMetadata,
type IssueWorkProduct,
} from "@paperclipai/shared";
import {
HttpError,
conflict,
notFound,
payloadTooLarge,
unprocessable,
unsupportedMediaType,
} from "../errors.js";
import type { StorageService } from "../storage/types.js";
import { documentAnnotationService } from "./document-annotations.js";
import { documentService, issueDocumentSelect, mapIssueDocumentRow } from "./documents.js";
export type EnsureArtifactReviewWorkProduct = Pick<
IssueWorkProduct,
"id" | "companyId" | "issueId" | "type" | "provider" | "metadata" | "title" | "createdByRunId" | "sourceTrust"
>;
export interface EnsureArtifactReviewDocumentInput {
issue: { id: string; companyId: string };
workProduct: EnsureArtifactReviewWorkProduct;
}
type RemappedAnnotations = Awaited<
ReturnType<ReturnType<typeof documentAnnotationService>["remapOpenThreadsForDocument"]>
>;
export interface EnsureArtifactReviewDocumentResult {
document: ReturnType<typeof mapIssueDocumentRow> & { body?: string };
created: boolean;
revisionChanged: boolean;
remappedAnnotations: RemappedAnnotations;
}
/**
* Materializes the review IssueDocument for an eligible Markdown
* attachment-backed work product under the deterministic
* `artifact-review-<workProductId>` key. The operation is idempotent: repeated
* calls return the existing document and only write a new revision when the
* review content or its work-product provenance changed.
*/
export function artifactReviewDocumentService(db: Db, storage: StorageService) {
const documentsSvc = documentService(db);
const annotationsSvc = documentAnnotationService(db);
const getExistingByKey = async (issueId: string, key: string) => {
const document = await db
.select(issueDocumentSelect)
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
.then((rows) => (rows[0] ? mapIssueDocumentRow(rows[0], true) : null));
if (!document) return null;
const latestRevision = document.latestRevisionId
? await db
.select({
createdByAgentId: documentRevisions.createdByAgentId,
createdByUserId: documentRevisions.createdByUserId,
createdByRunId: documentRevisions.createdByRunId,
})
.from(documentRevisions)
.where(and(
eq(documentRevisions.id, document.latestRevisionId),
eq(documentRevisions.documentId, document.id),
eq(documentRevisions.companyId, document.companyId),
))
.then((rows) => rows[0] ?? null)
: null;
return { document, latestRevision };
};
const readMarkdownAttachmentBody = async (
issue: { id: string; companyId: string },
metadata: AttachmentArtifactWorkProductMetadata,
) => {
const attachment = await db
.select({
id: issueAttachments.id,
companyId: issueAttachments.companyId,
issueId: issueAttachments.issueId,
objectKey: assets.objectKey,
contentType: assets.contentType,
byteSize: assets.byteSize,
originalFilename: assets.originalFilename,
createdByAgentId: assets.createdByAgentId,
createdByUserId: assets.createdByUserId,
})
.from(issueAttachments)
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
.where(eq(issueAttachments.id, metadata.attachmentId))
.then((rows) => rows[0] ?? null);
// Unknown and cross-scope attachment ids are deliberately
// indistinguishable: canonical metadata must reference an attachment on
// the same issue and company.
if (!attachment || attachment.companyId !== issue.companyId || attachment.issueId !== issue.id) {
throw unprocessable("Work product attachment must reference an attachment on the same issue", {
code: "invalid_attachment_artifact_metadata",
attachmentId: metadata.attachmentId,
});
}
if (!isMarkdownAttachmentContent(attachment)) {
throw unsupportedMediaType("Work product attachment is not Markdown", {
code: "unsupported_review_document_content_type",
contentType: attachment.contentType,
});
}
if (attachment.byteSize > MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES) {
throw payloadTooLarge("Markdown attachment exceeds the review document size limit", {
code: "review_document_too_large",
byteSize: attachment.byteSize,
maxBytes: MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES,
});
}
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of object.stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buffer.length;
// Defense in depth: enforce the cap during streaming too, so a
// metadata/object size mismatch can never exceed the limit.
if (total > MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES) {
object.stream.destroy();
throw payloadTooLarge("Markdown attachment exceeds the review document size limit", {
code: "review_document_too_large",
maxBytes: MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES,
});
}
chunks.push(buffer);
}
let body: string;
try {
body = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
} catch {
throw unprocessable("Markdown attachment is not valid UTF-8", {
code: "invalid_review_document_encoding",
attachmentId: attachment.id,
});
}
return { attachment, body };
};
return {
ensureForWorkProduct: async (
input: EnsureArtifactReviewDocumentInput,
): Promise<EnsureArtifactReviewDocumentResult> => {
const { issue, workProduct } = input;
if (workProduct.issueId !== issue.id || workProduct.companyId !== issue.companyId) {
throw notFound("Work product not found");
}
const metadata = getAttachmentArtifactWorkProductMetadata(workProduct);
if (!metadata) {
throw unprocessable("Work product is not an attachment-backed Paperclip artifact", {
code: "not_attachment_backed_artifact",
workProductId: workProduct.id,
});
}
if (!isMarkdownAttachmentContent(metadata)) {
throw unsupportedMediaType("Work product attachment is not Markdown", {
code: "unsupported_review_document_content_type",
contentType: metadata.contentType,
});
}
const { attachment, body } = await readMarkdownAttachmentBody(issue, metadata);
const key = artifactReviewDocumentKey(workProduct.id);
// The review document represents agent-authored attachment content, so
// attribution promotes the attachment/work-product provenance instead of
// the actor who triggered materialization.
const attribution = {
createdByAgentId: attachment.createdByAgentId ?? null,
createdByUserId: attachment.createdByUserId ?? null,
createdByRunId: workProduct.createdByRunId ?? null,
sourceTrust: workProduct.sourceTrust ?? null,
};
for (let attempt = 0; attempt < 2; attempt += 1) {
const existingState = await getExistingByKey(issue.id, key);
const existing = existingState?.document ?? null;
const latestRevision = existingState?.latestRevision ?? null;
const reviewStateMatches = existing &&
existing.body === body &&
existing.title === (workProduct.title ?? null) &&
existing.format === "markdown" &&
latestRevision?.createdByAgentId === attribution.createdByAgentId &&
latestRevision.createdByUserId === attribution.createdByUserId &&
latestRevision.createdByRunId === attribution.createdByRunId &&
isDeepStrictEqual(existing.sourceTrust ?? null, attribution.sourceTrust);
if (reviewStateMatches) {
return { document: existing, created: false, revisionChanged: false, remappedAnnotations: [] };
}
try {
const result = await documentsSvc.upsertIssueDocument({
issueId: issue.id,
key,
title: workProduct.title ?? null,
format: "markdown",
body,
changeSummary: existing
? "Synced from the work product attachment"
: "Materialized from the Markdown work product attachment",
baseRevisionId: existing?.latestRevisionId ?? null,
...attribution,
lockedDocumentStrategy: "conflict",
});
const remappedAnnotations = result.created
? []
: await annotationsSvc.remapOpenThreadsForDocument({
issueId: issue.id,
key: result.document.key,
documentId: result.document.id,
nextRevisionId: result.document.latestRevisionId,
nextRevisionNumber: result.document.latestRevisionNumber,
nextBody: result.document.body,
});
return {
document: result.document,
created: result.created,
revisionChanged: true,
remappedAnnotations,
};
} catch (error) {
// A concurrent materialization or document write raced this one.
// Re-read once so identical content converges on the winner.
if (error instanceof HttpError && error.status === 409 && attempt === 0) continue;
throw error;
}
}
throw conflict("Concurrent review-document updates did not converge", { key });
},
};
}

View File

@ -23,6 +23,7 @@ export {
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
export { assetService } from "./assets.js";
export { documentService, extractLegacyPlanBody } from "./documents.js";
export { artifactReviewDocumentService } from "./artifact-review-documents.js";
export { statusCardService } from "./status-cards.js";
export { finalizeStatusCardsForStalledGeneration } from "./status-card-finalization.js";
export { documentAnnotationService } from "./document-annotations.js";

View File

@ -342,6 +342,8 @@ export const issuesApi = {
unlinkApproval: (id: string, approvalId: string) =>
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
ensureWorkProductReviewDocument: (id: string, workProductId: string) =>
api.post<IssueDocument>(`/issues/${id}/work-products/${workProductId}/review-document`, {}),
createWorkProduct: (id: string, data: Record<string, unknown>) =>
api.post<IssueWorkProduct>(`/issues/${id}/work-products`, data),
updateWorkProduct: (id: string, data: Record<string, unknown>) =>

View File

@ -5,7 +5,12 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { issueStatusText } from "@/lib/status-colors";
import { copyTextToClipboard } from "@/lib/clipboard";
import { Link } from "@/lib/router";
import { deriveOriginatingActor, type Issue, type IssueLabel } from "@paperclipai/shared";
import {
deriveOriginatingActor,
isArtifactReviewDocumentKey,
type Issue,
type IssueLabel,
} from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { accessApi } from "../../api/access";
import { agentsApi } from "../../api/agents";
@ -225,10 +230,15 @@ export function IssueProperties({
enabled: taskChatShellEnabled,
});
const { data: paneTabDocuments } = useIssueDocuments(taskChatShellEnabled ? issue.id : null);
// Proxy `artifact-review-*` documents surface only through their Work
// product row, so they must not summon the Plan or Documents surfaces.
const paneTabStandaloneDocuments = (paneTabDocuments ?? []).filter(
(doc) => !isArtifactReviewDocumentKey(doc.key),
);
const hasPlanTab =
Boolean(paneTabPlanDocument)
|| (paneTabAcceptedPlans?.length ?? 0) > 0
|| (paneTabDocuments?.length ?? 0) > 0
|| paneTabStandaloneDocuments.length > 0
|| issue.workMode === "planning";
// Artifacts covers the same three sources the tab body composes: work
// products, documents (redundant with the Plan tab, intentionally), and
@ -236,7 +246,7 @@ export function IssueProperties({
// no longer summon the tab.
const hasArtifactsTab =
(paneTabWorkProducts?.length ?? 0) > 0
|| (paneTabDocuments?.length ?? 0) > 0
|| paneTabStandaloneDocuments.length > 0
|| selectAgentArtifactAttachments(paneTabAttachments, paneTabWorkProducts).length > 0;
const [paneTab, setPaneTab] = useState("properties");
// Once a plan document exists, surface it: switch the pane to the Plan tab so

View File

@ -1,10 +1,22 @@
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Issue, IssueDocument, IssueWorkProduct } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
AttachmentArtifactWorkProductMetadata,
Issue,
IssueDocument,
IssueWorkProduct,
} from "@paperclipai/shared";
import {
MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES,
artifactReviewDocumentKey,
getMarkdownWorkProductAttachmentMetadata,
isArtifactReviewDocumentKey,
} from "@paperclipai/shared";
import {
ChevronDown,
ChevronRight,
Download,
ExternalLink,
FileText,
GitBranch,
@ -15,6 +27,7 @@ import {
Server,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
@ -122,6 +135,186 @@ function WorkProductRow({ workProduct }: { workProduct: IssueWorkProduct }) {
return <div className={ROW_CLASS}>{body}</div>;
}
/**
* Work-product row for an eligible Markdown artifact (LOOA-1533 gap): expands
* in place into the shipped document review surface backed by the
* server-materialized `artifact-review-<workProductId>` issue document instead
* of opening the raw attachment. Raw open and download stay as explicit
* secondary actions.
*/
function MarkdownWorkProductRow({
issueId,
workProduct,
metadata,
reviewDoc,
openRequestId,
}: {
issueId: string;
workProduct: IssueWorkProduct;
metadata: AttachmentArtifactWorkProductMetadata;
reviewDoc: IssueDocument | undefined;
openRequestId?: number;
}) {
const [expanded, setExpanded] = useState(false);
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
const headerRef = useRef<HTMLDivElement | null>(null);
const location = useLocation();
const queryClient = useQueryClient();
const badge = workProductStatusBadge(workProduct.status);
const Chevron = expanded ? ChevronDown : ChevronRight;
const tooLarge = metadata.byteSize > MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES;
const ensure = useMutation({
mutationFn: () => issuesApi.ensureWorkProductReviewDocument(issueId, workProduct.id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId) });
},
});
const requestPreview = () => {
if (reviewDoc || tooLarge) return;
if (ensure.isPending || ensure.isSuccess || ensure.isError) return;
ensure.mutate();
};
const handleToggle = () => {
setExpanded((open) => {
if (!open) requestPreview();
return !open;
});
};
useEffect(() => {
if (openRequestId === undefined) return;
setExpanded(true);
}, [openRequestId]);
useEffect(() => {
if (openRequestId === undefined || !expanded) return;
headerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [expanded, openRequestId]);
// Deep links land before the user clicks, so the deep-link expansion has to
// request materialization the same way a manual expand does.
useEffect(() => {
if (openRequestId === undefined) return;
requestPreview();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openRequestId, reviewDoc]);
const unsupportedError =
ensure.error instanceof ApiError && [413, 415, 422].includes(ensure.error.status);
let expandedBody: React.ReactNode;
if (tooLarge) {
expandedBody = (
<p className="text-sm text-muted-foreground">
This Markdown file is too large to preview. Use Raw or Download instead.
</p>
);
} else if (reviewDoc) {
expandedBody = reviewDoc.body.trim().length > 0 ? (
<IssueDocumentAnnotations
issueId={issueId}
doc={reviewDoc}
bodyMarkdown={reviewDoc.body}
draftDirty={false}
draftConflicted={false}
historicalPreview={false}
locationHash={location.hash}
panelOpen={annotationPanelOpen}
onPanelOpenChange={setAnnotationPanelOpen}
panelPlacement="popover"
>
<MarkdownBody>{reviewDoc.body}</MarkdownBody>
</IssueDocumentAnnotations>
) : (
<p className="text-sm text-muted-foreground">Document is empty.</p>
);
} else if (ensure.isError) {
expandedBody = (
<div className="flex flex-col items-start gap-1.5">
<p className="text-sm text-muted-foreground">
{unsupportedError
? "This file can't be previewed as Markdown. Use Raw or Download instead."
: "Preview failed to load."}
</p>
{!unsupportedError ? (
<button
type="button"
className="rounded-md border border-border px-2 py-0.5 text-(length:--text-micro) text-muted-foreground hover:bg-accent/50"
onClick={() => {
ensure.reset();
ensure.mutate();
}}
>
Retry
</button>
) : null}
</div>
);
} else {
expandedBody = <p className="text-sm text-muted-foreground">Preparing preview</p>;
}
return (
<div className="rounded-md border border-border bg-card/50">
<div ref={headerRef} className="flex items-center hover:bg-accent/50">
<button
type="button"
onClick={handleToggle}
className="flex min-w-0 flex-1 items-center gap-2 px-2.5 py-1.5 text-left text-sm"
aria-expanded={expanded}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{workProduct.title}</span>
{badge ? (
<span
className="status-chip inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-(length:--text-nano) leading-none whitespace-nowrap"
style={{ "--sc": `var(${badge.cssVar})` } as CSSProperties}
>
{badge.label}
</span>
) : null}
{reviewDoc ? (
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">
{`Rev ${reviewDoc.latestRevisionNumber ?? 1}`}
</span>
) : null}
<Chevron className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
{reviewDoc ? (
<DocumentAnnotationsCountChip
issueId={issueId}
docKey={reviewDoc.key}
panelOpen={annotationPanelOpen}
onToggle={() => setAnnotationPanelOpen((open) => !open)}
/>
) : null}
<a
href={metadata.openPath}
target="_blank"
rel="noreferrer"
aria-label={`Open raw ${workProduct.title}`}
title="Open raw"
className="shrink-0 px-1.5 py-1.5 text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
</a>
<a
href={metadata.downloadPath}
aria-label={`Download ${workProduct.title}`}
title="Download"
className="shrink-0 py-1.5 pr-2 pl-0.5 text-muted-foreground hover:text-foreground"
>
<Download className="h-3 w-3" />
</a>
</div>
{expanded ? (
<div className="border-t border-border px-2.5 py-2">{expandedBody}</div>
) : null}
</div>
);
}
function DocumentRow({
issueId,
doc,
@ -215,7 +408,10 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink }: IssuePr
const { data: documents } = useIssueDocuments(issue.id);
const workProductRows = workProducts ?? [];
const documentRows = documents ?? [];
// Proxy review documents (`artifact-review-*`) present only through their
// originating Work product row, never as standalone Documents rows.
const documentRows = (documents ?? []).filter((doc) => !isArtifactReviewDocumentKey(doc.key));
const reviewDocsByKey = new Map((documents ?? []).map((doc) => [doc.key, doc]));
const fileRows = selectAgentArtifactAttachments(attachments, workProducts);
if (workProductRows.length === 0 && documentRows.length === 0 && fileRows.length === 0) {
@ -232,11 +428,30 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink }: IssuePr
<>
<SectionHeading>Work products</SectionHeading>
<ul className="flex flex-col gap-1">
{workProductRows.map((wp) => (
<li key={wp.id}>
<WorkProductRow workProduct={wp} />
</li>
))}
{workProductRows.map((wp) => {
const markdownMetadata = getMarkdownWorkProductAttachmentMetadata(wp);
if (!markdownMetadata) {
return (
<li key={wp.id}>
<WorkProductRow workProduct={wp} />
</li>
);
}
const reviewKey = artifactReviewDocumentKey(wp.id);
return (
<li key={wp.id}>
<MarkdownWorkProductRow
issueId={issue.id}
workProduct={wp}
metadata={markdownMetadata}
reviewDoc={reviewDocsByKey.get(reviewKey)}
openRequestId={documentDeepLink?.documentKey === reviewKey
? documentDeepLink.requestId
: undefined}
/>
</li>
);
})}
</ul>
</>
) : null}

View File

@ -0,0 +1,344 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Issue, IssueAttachment, IssueDocument, IssueWorkProduct } from "@paperclipai/shared";
import { artifactReviewDocumentKey } from "@paperclipai/shared";
import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab";
import { ApiError } from "@/api/client";
const mockIssuesApi = vi.hoisted(() => ({
listAttachments: vi.fn(async (): Promise<unknown[]> => []),
listWorkProducts: vi.fn(async (): Promise<unknown[]> => []),
ensureWorkProductReviewDocument: vi.fn(async (): Promise<unknown> => ({})),
}));
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
const mockUseIssueDocuments = vi.hoisted(() =>
vi.fn((): { data: IssueDocument[] } => ({ data: [] })),
);
vi.mock("@/hooks/useIssueDocuments", () => ({ useIssueDocuments: mockUseIssueDocuments }));
vi.mock("@/lib/router", () => ({ useLocation: () => ({ hash: "" }) }));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => (
<div data-testid="markdown-body">{children}</div>
),
}));
vi.mock("@/components/IssueDocumentAnnotations", () => ({
DocumentAnnotationsCountChip: ({ docKey }: { docKey: string }) => (
<span data-testid={`annotation-count-${docKey}`} />
),
IssueDocumentAnnotations: ({ doc, children }: { doc: IssueDocument; children: React.ReactNode }) => (
<div data-testid={`annotation-surface-${doc.key}`} data-document-id={doc.id}>{children}</div>
),
}));
const ATTACHMENT_ID = "00000000-0000-4000-8000-000000000001";
const WORK_PRODUCT_ID = "11111111-1111-4111-8111-111111111111";
const REVIEW_KEY = artifactReviewDocumentKey(WORK_PRODUCT_ID);
const issue = { id: "issue-1", identifier: "PAP-1534", workMode: "standard" } as Issue;
function makeMarkdownWorkProduct(overrides: Partial<IssueWorkProduct> = {}): IssueWorkProduct {
const contentPath = `/api/attachments/${ATTACHMENT_ID}/content`;
return {
id: WORK_PRODUCT_ID,
companyId: "company-1",
projectId: null,
issueId: "issue-1",
executionWorkspaceId: null,
runtimeServiceId: null,
type: "artifact",
provider: "paperclip",
externalId: null,
title: "Verification report",
url: null,
status: "ready_for_review",
reviewState: "none",
isPrimary: false,
healthStatus: "unknown",
summary: null,
metadata: {
attachmentId: ATTACHMENT_ID,
contentType: "text/markdown",
byteSize: 64,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
originalFilename: "report.md",
},
createdByRunId: null,
createdAt: new Date("2026-08-01T12:00:00Z"),
updatedAt: new Date("2026-08-01T12:00:00Z"),
...overrides,
} as IssueWorkProduct;
}
function makeReviewDocument(overrides: Partial<IssueDocument> = {}): IssueDocument {
return {
id: "document-1",
companyId: "company-1",
issueId: "issue-1",
key: REVIEW_KEY,
title: "Verification report",
format: "markdown",
body: "# Report\n\nRendered body",
latestRevisionId: "revision-1",
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lockedAt: null,
lockedByAgentId: null,
lockedByUserId: null,
createdAt: new Date("2026-08-01T12:00:00Z"),
updatedAt: new Date("2026-08-01T12:00:00Z"),
...overrides,
} as IssueDocument;
}
function makeMarkdownAttachment(): IssueAttachment {
return {
id: ATTACHMENT_ID,
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: "asset-1",
provider: "local",
objectKey: `objects/${ATTACHMENT_ID}`,
contentType: "text/markdown",
byteSize: 64,
sha256: "0".repeat(64),
originalFilename: "report.md",
createdByAgentId: "agent-1",
createdByUserId: null,
createdAt: new Date("2026-08-01T12:00:00Z"),
updatedAt: new Date("2026-08-01T12:00:00Z"),
contentPath: `/api/attachments/${ATTACHMENT_ID}/content`,
} as IssueAttachment;
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
describe("markdown work product review row", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement("div");
document.body.appendChild(container);
Element.prototype.scrollIntoView = vi.fn();
mockIssuesApi.listAttachments.mockResolvedValue([]);
mockIssuesApi.listWorkProducts.mockResolvedValue([makeMarkdownWorkProduct()]);
mockUseIssueDocuments.mockReturnValue({ data: [] });
});
afterEach(async () => {
if (root) {
const currentRoot = root;
await act(async () => currentRoot.unmount());
root = null;
}
container.remove();
});
async function renderTab(props: { documentDeepLink?: { requestId: number; documentKey: string } | null } = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
root = createRoot(container);
const currentRoot = root;
await act(async () =>
currentRoot.render(
<QueryClientProvider client={queryClient}>
<IssuePropertiesArtifactsTab issue={issue} {...props} />
</QueryClientProvider>,
),
);
await waitForAssertion(() => {
expect(container.textContent).toContain("Verification report");
});
}
function expandButton() {
return container.querySelector('button[aria-expanded]') as HTMLButtonElement;
}
it("renders an expandable row with explicit raw and download actions", async () => {
await renderTab();
expect(expandButton().textContent).toContain("Verification report");
const raw = container.querySelector('a[title="Open raw"]') as HTMLAnchorElement;
const download = container.querySelector('a[title="Download"]') as HTMLAnchorElement;
expect(raw?.getAttribute("href")).toBe(`/api/attachments/${ATTACHMENT_ID}/content`);
expect(raw?.getAttribute("target")).toBe("_blank");
expect(download?.getAttribute("href")).toBe(`/api/attachments/${ATTACHMENT_ID}/content?download=1`);
});
it("expands into the existing document surface without a server call when the document exists", async () => {
mockUseIssueDocuments.mockReturnValue({ data: [makeReviewDocument()] });
await renderTab();
// The proxy document maps onto the work-product row instead of a
// standalone Documents row.
expect(container.textContent).not.toContain("Documents");
expect(container.querySelector(`[data-testid="annotation-count-${REVIEW_KEY}"]`)).not.toBeNull();
await act(async () => expandButton().click());
expect(
container.querySelector(`[data-testid="annotation-surface-${REVIEW_KEY}"]`)?.getAttribute("data-document-id"),
).toBe("document-1");
expect(container.querySelector('[data-testid="markdown-body"]')?.textContent).toContain("Rendered body");
expect(mockIssuesApi.ensureWorkProductReviewDocument).not.toHaveBeenCalled();
});
it("materializes the document on first expand", async () => {
mockIssuesApi.ensureWorkProductReviewDocument.mockResolvedValue(makeReviewDocument());
await renderTab();
await act(async () => expandButton().click());
expect(container.textContent).toContain("Preparing preview…");
await waitForAssertion(() => {
expect(mockIssuesApi.ensureWorkProductReviewDocument).toHaveBeenCalledWith("issue-1", WORK_PRODUCT_ID);
});
expect(mockIssuesApi.ensureWorkProductReviewDocument).toHaveBeenCalledTimes(1);
});
it("shows an unsupported state without retry for typed rejections", async () => {
mockIssuesApi.ensureWorkProductReviewDocument.mockRejectedValue(
new ApiError("Work product attachment is not Markdown", 415, {}),
);
await renderTab();
await act(async () => expandButton().click());
await waitForAssertion(() => {
expect(container.textContent).toContain("can't be previewed as Markdown");
});
expect(container.textContent).not.toContain("Retry");
});
it("offers an explicit retry for recoverable errors", async () => {
mockIssuesApi.ensureWorkProductReviewDocument.mockRejectedValue(
new ApiError("Internal error", 500, {}),
);
await renderTab();
await act(async () => expandButton().click());
await waitForAssertion(() => {
expect(container.textContent).toContain("Preview failed to load.");
});
const retry = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Retry",
);
expect(retry).toBeDefined();
await act(async () => retry?.click());
await waitForAssertion(() => {
expect(mockIssuesApi.ensureWorkProductReviewDocument).toHaveBeenCalledTimes(2);
});
});
it("marks oversized markdown as too large without calling the server", async () => {
mockIssuesApi.listWorkProducts.mockResolvedValue([
makeMarkdownWorkProduct({
metadata: {
...(makeMarkdownWorkProduct().metadata as Record<string, unknown>),
byteSize: 512 * 1024 + 1,
},
}),
]);
await renderTab();
await act(async () => expandButton().click());
expect(container.textContent).toContain("too large to preview");
expect(mockIssuesApi.ensureWorkProductReviewDocument).not.toHaveBeenCalled();
});
it("expands from a document deep link and requests materialization", async () => {
mockIssuesApi.ensureWorkProductReviewDocument.mockResolvedValue(makeReviewDocument());
await renderTab({ documentDeepLink: { documentKey: REVIEW_KEY, requestId: 1 } });
await waitForAssertion(() => {
expect(container.querySelector('button[aria-expanded="true"]')).not.toBeNull();
expect(mockIssuesApi.ensureWorkProductReviewDocument).toHaveBeenCalledTimes(1);
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
});
});
it("hides the promoted markdown attachment from Files but keeps loose attachments", async () => {
const loose = {
...makeMarkdownAttachment(),
id: "22222222-2222-4222-8222-222222222222",
originalFilename: "loose-notes.md",
contentPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content",
};
mockIssuesApi.listAttachments.mockResolvedValue([makeMarkdownAttachment(), loose]);
await renderTab();
await waitForAssertion(() => {
expect(container.textContent).toContain("loose-notes.md");
});
expect(container.textContent).not.toContain("report.md");
const looseLink = Array.from(container.querySelectorAll("a")).find(
(anchor) => anchor.textContent?.includes("loose-notes.md"),
);
expect(looseLink?.getAttribute("href")).toBe("/api/attachments/22222222-2222-4222-8222-222222222222/content");
});
it("keeps non-markdown work products on the raw link row", async () => {
const contentPath = `/api/attachments/${ATTACHMENT_ID}/content`;
mockIssuesApi.listWorkProducts.mockResolvedValue([
makeMarkdownWorkProduct({
metadata: {
attachmentId: ATTACHMENT_ID,
contentType: "application/pdf",
byteSize: 64,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
originalFilename: "report.pdf",
},
}),
]);
await renderTab();
await waitForAssertion(() => {
const row = Array.from(container.querySelectorAll("a")).find(
(anchor) => anchor.textContent?.includes("Verification report"),
);
expect(row?.getAttribute("href")).toBe(contentPath);
expect(row?.getAttribute("target")).toBe("_blank");
});
expect(container.querySelector("button[aria-expanded]")).toBeNull();
});
});

View File

@ -1,6 +1,7 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Issue, IssueDocument, IssueThreadInteraction } from "@paperclipai/shared";
import { isArtifactReviewDocumentKey } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection";
@ -92,8 +93,11 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps)
const hasPlans = (data?.length ?? 0) > 0;
const pendingPlanConfirmation = hasPendingPlanConfirmation(interactions);
// Every other non-system document (e.g. `synthesis`) renders below the plan;
// the `plan` doc itself stays on its dedicated annotated surface above.
const otherDocuments = (documents ?? []).filter((doc) => doc.key !== "plan");
// the `plan` doc itself stays on its dedicated annotated surface above, and
// proxy `artifact-review-*` documents surface only through their Work product.
const otherDocuments = (documents ?? []).filter(
(doc) => doc.key !== "plan" && !isArtifactReviewDocumentKey(doc.key),
);
if (!planDocument && !hasPlans && otherDocuments.length === 0) {
return (

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared";
import {
documentDisplayTitle,
getAttachmentBackedWorkProductAttachmentIds,
isAgentAttachment,
selectAgentArtifactAttachments,
workProductHref,
@ -112,6 +113,54 @@ describe("selectAgentArtifactAttachments", () => {
it("tolerates missing inputs", () => {
expect(selectAgentArtifactAttachments(null, null)).toEqual([]);
});
it("dedupes markdown attachments promoted to work products", () => {
// Markdown is excluded from the binary Output surface, so this dedupe must
// not depend on getPromotedOutputAttachmentIds (LOOA-1533 duplicate rows).
const promotedId = "00000000-0000-4000-8000-000000000002";
const promoted = makeAttachment({
id: promotedId,
createdByAgentId: "agent-1",
contentType: "text/markdown",
originalFilename: "report.md",
});
const workProduct = makePromotingWorkProduct(promotedId);
workProduct.metadata = {
...(workProduct.metadata as Record<string, unknown>),
contentType: "text/markdown",
originalFilename: "report.md",
};
expect(selectAgentArtifactAttachments([promoted], [workProduct])).toEqual([]);
});
});
describe("getAttachmentBackedWorkProductAttachmentIds", () => {
it("collects attachment ids across content types", () => {
const imageId = "00000000-0000-4000-8000-000000000001";
const markdownId = "00000000-0000-4000-8000-000000000002";
const markdownWorkProduct = makePromotingWorkProduct(markdownId);
markdownWorkProduct.metadata = {
...(markdownWorkProduct.metadata as Record<string, unknown>),
contentType: "text/markdown",
originalFilename: "report.md",
};
const ids = getAttachmentBackedWorkProductAttachmentIds([
makePromotingWorkProduct(imageId),
markdownWorkProduct,
]);
expect(ids).toEqual(new Set([imageId, markdownId]));
});
it("ignores non-canonical or non-paperclip work products", () => {
const attachmentId = "00000000-0000-4000-8000-000000000003";
const foreign = { ...makePromotingWorkProduct(attachmentId), provider: "github" };
const invalid = {
...makePromotingWorkProduct(attachmentId),
metadata: { attachmentId, openPath: "https://evil.example/content" },
};
expect(getAttachmentBackedWorkProductAttachmentIds([foreign, invalid])).toEqual(new Set());
expect(getAttachmentBackedWorkProductAttachmentIds(null)).toEqual(new Set());
});
});
describe("workProductHref", () => {

View File

@ -1,5 +1,5 @@
import type { IssueAttachment, IssueDocumentSummary, IssueWorkProduct } from "@paperclipai/shared";
import { getPromotedOutputAttachmentIds } from "./issue-output";
import { getAttachmentArtifactWorkProductMetadata } from "@paperclipai/shared";
/**
* Selectors for the properties pane's Artifacts tab (PAP-491): which
@ -19,6 +19,23 @@ export function isAgentAttachment(
return !attachment.createdByUserId && !attachment.issueCommentId;
}
/**
* Attachment ids owned by valid attachment-backed Paperclip work products.
* Unlike `getPromotedOutputAttachmentIds`, this includes document-like content
* types (e.g. Markdown) that the binary Output surface intentionally excludes,
* so it is the right dedupe set for the Artifacts tab's Files section.
*/
export function getAttachmentBackedWorkProductAttachmentIds(
workProducts: IssueWorkProduct[] | null | undefined,
): Set<string> {
const ids = new Set<string>();
for (const workProduct of workProducts ?? []) {
const metadata = getAttachmentArtifactWorkProductMetadata(workProduct);
if (metadata) ids.add(metadata.attachmentId);
}
return ids;
}
/**
* Agent-authored attachments minus the ones already promoted to
* attachment-backed work products (`metadata.attachmentId`), so the Artifacts
@ -28,7 +45,7 @@ export function selectAgentArtifactAttachments(
attachments: IssueAttachment[] | null | undefined,
workProducts: IssueWorkProduct[] | null | undefined,
): IssueAttachment[] {
const promoted = getPromotedOutputAttachmentIds(workProducts);
const promoted = getAttachmentBackedWorkProductAttachmentIds(workProducts);
return (attachments ?? []).filter(
(attachment) => isAgentAttachment(attachment) && !promoted.has(attachment.id),
);

View File

@ -1,12 +1,7 @@
import type { IssueAttachment } from "@paperclipai/shared";
import { isMarkdownAttachmentContent } from "@paperclipai/shared";
import { isVideoLikeOutput } from "./issue-output";
const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([
"application/octet-stream",
"binary/octet-stream",
"application/x-binary",
]);
type AttachmentPathLike = {
contentPath: string;
openPath?: string;
@ -42,17 +37,5 @@ export function isVideoAttachment(
export function isMarkdownAttachment(
attachment: Pick<IssueAttachment, "contentType" | "originalFilename">,
) {
const contentType = normalizedContentType(attachment);
if (
contentType === "text/markdown" ||
contentType === "text/x-markdown" ||
contentType === "application/markdown" ||
contentType === "application/x-markdown"
) {
return true;
}
const filename = (attachment.originalFilename ?? "").toLowerCase();
if (!filename.endsWith(".md") && !filename.endsWith(".markdown")) return false;
return contentType === "text/plain" || GENERIC_ATTACHMENT_CONTENT_TYPES.has(contentType);
return isMarkdownAttachmentContent(attachment);
}