fix: preserve verified reuse and prior context in file completion
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5fda8df551
commit
b3426bb5ce
|
|
@ -1212,7 +1212,11 @@ accessible work product registered by that run with a published URL. A prior
|
|||
run's output cannot stand in for a newly requested file. A same-run controller
|
||||
restart keeps the receipt; a replacement can inspect and re-register preserved
|
||||
workspace bytes without user bookkeeping. Follow-ups requesting no new file can
|
||||
still reference existing downloads. A `workspace_file` locator alone is not delivery
|
||||
still reference existing downloads. Prior downloads can also accompany a valid
|
||||
current output as context. Authorized chat attachment reuse supplies a current-run
|
||||
publication receipt for its verified clone; older reuse receipts must additionally
|
||||
match an intact company-scoped source's filename, size, and hash.
|
||||
A `workspace_file` locator alone is not delivery
|
||||
evidence: it neither verifies the file nor preserves its bytes after cleanup.
|
||||
Reading or reviewing an existing file for an inline answer does not
|
||||
require uploading that input. Ambiguous prose remains subject to the runner's
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
type ChatAttachmentReuseSource,
|
||||
} from "./chat-attachment-reuse.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { validateNativeDeliverableEvidence } from "./native-deliverable-feedback.js";
|
||||
|
||||
describe("native same-conversation chat attachment reuse", () => {
|
||||
let temporary: Awaited<
|
||||
|
|
@ -458,6 +459,8 @@ describe("native same-conversation chat attachment reuse", () => {
|
|||
attachmentId: expect.any(String),
|
||||
workProductId: expect.any(String),
|
||||
commentId: expect.any(String),
|
||||
filename: "earlier.txt",
|
||||
byteSize: sourceBody.length,
|
||||
sha256: createHash("sha256").update(sourceBody).digest("hex"),
|
||||
},
|
||||
});
|
||||
|
|
@ -522,6 +525,53 @@ describe("native same-conversation chat attachment reuse", () => {
|
|||
});
|
||||
expect(receipts["reuse-earlier-v1"]?.result).toEqual(first);
|
||||
expect(receipts["reuse-earlier-v2"]?.result).toEqual(duplicate);
|
||||
const verifyReceipt = (semanticToolReceipts: unknown) => validateNativeDeliverableEvidence(db, {
|
||||
companyId, issueId, runId, objective: "Send me that file again.", semanticToolReceipts,
|
||||
}, {
|
||||
schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Prepared the requested existing file.",
|
||||
completionClaim: { contractRevision: "test", objectiveSatisfied: true, criteria: [], remainingWork: [] },
|
||||
evidence: [{ ref: `deliverable:${preparedId}` }], verification: [], attentionRequests: [], artifacts: [],
|
||||
});
|
||||
await expect(verifyReceipt(receipts)).resolves.toBeUndefined();
|
||||
for (const field of ["filename", "byteSize"]) {
|
||||
const corrupted = structuredClone(receipts);
|
||||
for (const receipt of Object.values(corrupted)) {
|
||||
const prepared = (receipt.result as { prepared?: Record<string, unknown> }).prepared;
|
||||
if (prepared) prepared[field] = field === "filename" ? "different.txt" : sourceBody.length + 1;
|
||||
}
|
||||
await expect(verifyReceipt(corrupted)).rejects.toThrow("this run's requested output");
|
||||
}
|
||||
const legacy = structuredClone(receipts);
|
||||
for (const receipt of Object.values(legacy)) {
|
||||
const prepared = (receipt.result as { prepared?: Record<string, unknown> }).prepared;
|
||||
if (prepared) { delete prepared.filename; delete prepared.byteSize; }
|
||||
}
|
||||
await expect(verifyReceipt(legacy)).resolves.toBeUndefined();
|
||||
const [sourceRow] = await db.select({ attachment: issueAttachments, asset: assets }).from(issueAttachments)
|
||||
.innerJoin(assets, eq(assets.id, issueAttachments.assetId)).where(eq(issueAttachments.id, sourceAttachmentId));
|
||||
const foreignCompanyId = randomUUID();
|
||||
await db.insert(companies).values({ id: foreignCompanyId, name: "Unrelated receipt source" });
|
||||
try {
|
||||
for (const mutation of ["missing", "filename", "size", "hash", "foreign company"]) {
|
||||
if (mutation === "missing") await db.delete(issueAttachments).where(eq(issueAttachments.id, sourceAttachmentId));
|
||||
if (mutation === "filename") await db.update(assets).set({ originalFilename: "different.txt" }).where(eq(assets.id, sourceRow.asset.id));
|
||||
if (mutation === "size") await db.update(assets).set({ byteSize: sourceBody.length + 1 }).where(eq(assets.id, sourceRow.asset.id));
|
||||
if (mutation === "hash") await db.update(assets).set({ sha256: "0".repeat(64) }).where(eq(assets.id, sourceRow.asset.id));
|
||||
if (mutation === "foreign company") await db.update(issueAttachments).set({ companyId: foreignCompanyId }).where(eq(issueAttachments.id, sourceAttachmentId));
|
||||
try {
|
||||
await expect(verifyReceipt(legacy)).rejects.toThrow("this run's requested output");
|
||||
// New receipts preserve the verified tuple even after the source is removed.
|
||||
await expect(verifyReceipt(receipts)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
if (mutation === "missing") await db.insert(issueAttachments).values(sourceRow.attachment);
|
||||
else await db.update(issueAttachments).set({ companyId }).where(eq(issueAttachments.id, sourceAttachmentId));
|
||||
await db.update(assets).set({ originalFilename: sourceRow.asset.originalFilename,
|
||||
byteSize: sourceRow.asset.byteSize, sha256: sourceRow.asset.sha256 }).where(eq(assets.id, sourceRow.asset.id));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await db.delete(companies).where(eq(companies.id, foreignCompanyId));
|
||||
}
|
||||
const completedResult = mergeHeartbeatRunResultJson(
|
||||
{
|
||||
...(run.resultJson ?? {}),
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ export type PreparedReusedChatAttachment = {
|
|||
attachmentId: string;
|
||||
workProductId: string;
|
||||
commentId: string;
|
||||
filename: string;
|
||||
byteSize: number;
|
||||
sha256: string;
|
||||
};
|
||||
};
|
||||
|
|
@ -1530,6 +1532,8 @@ export async function prepareReusedChatAttachment(input: {
|
|||
attachmentId: attachment.id,
|
||||
workProductId: attachment.artifactWorkProductId,
|
||||
commentId: comment.id,
|
||||
filename: input.source.filename,
|
||||
byteSize: body.length,
|
||||
sha256: attachment.sha256,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,21 +16,46 @@ function record(value: unknown): Record<string, unknown> {
|
|||
? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function hasCurrentPublicationReceipt(receipts: unknown, attachment: {
|
||||
async function hasCurrentPublicationReceipt(db: Db, companyId: string, receipts: unknown, attachment: {
|
||||
id: string; filename: string | null; byteSize: number; sha256: string;
|
||||
}): boolean {
|
||||
return Object.values(record(receipts)).some(value => {
|
||||
}): Promise<boolean> {
|
||||
for (const value of Object.values(record(receipts))) {
|
||||
const receipt = record(value);
|
||||
const input = record(receipt.input);
|
||||
const result = record(receipt.result);
|
||||
return receipt.operationId === "register_deliverable" &&
|
||||
(result.disposition === "applied" || result.disposition === "duplicate") &&
|
||||
result.commandId === `deliverable-prepared:${attachment.id}` &&
|
||||
Array.isArray(result.entityRefs) && result.entityRefs[0] === attachment.id &&
|
||||
typeof input.filename === "string" && input.filename.trim() === attachment.filename &&
|
||||
input.byteSize === attachment.byteSize &&
|
||||
typeof input.sha256 === "string" && input.sha256.trim().toLowerCase() === attachment.sha256.toLowerCase();
|
||||
});
|
||||
if ((result.disposition !== "applied" && result.disposition !== "duplicate") ||
|
||||
!Array.isArray(result.entityRefs) || result.entityRefs[0] !== attachment.id) continue;
|
||||
if (receipt.operationId === "register_deliverable" &&
|
||||
result.commandId === `deliverable-prepared:${attachment.id}` &&
|
||||
typeof input.filename === "string" && input.filename.trim() === attachment.filename &&
|
||||
input.byteSize === attachment.byteSize &&
|
||||
typeof input.sha256 === "string" && input.sha256.trim().toLowerCase() === attachment.sha256.toLowerCase()) return true;
|
||||
if (receipt.operationId !== "reuse_chat_attachment" ||
|
||||
result.commandId !== `chat-attachment-reused:${attachment.id}`) continue;
|
||||
const prepared = record(result.prepared);
|
||||
const source = record(result.source);
|
||||
if (prepared.attachmentId !== attachment.id ||
|
||||
source.attachmentId !== input.attachmentId || source.commentId !== input.sourceCommentId ||
|
||||
typeof prepared.sha256 !== "string" || prepared.sha256.toLowerCase() !== attachment.sha256.toLowerCase() ||
|
||||
typeof source.sha256 !== "string" || source.sha256.toLowerCase() !== attachment.sha256.toLowerCase()) continue;
|
||||
if ("filename" in prepared || "byteSize" in prepared) {
|
||||
if (prepared.filename === attachment.filename && prepared.byteSize === attachment.byteSize) return true;
|
||||
continue;
|
||||
}
|
||||
// Older committed reuse receipts contain the authenticated source and hash,
|
||||
// but not its filename/size. Only an intact, matching source can supply those
|
||||
// missing facts; this does not authorize a new reuse or bypass its tool gate.
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
||||
if (typeof source.attachmentId !== "string" || !uuid.test(source.attachmentId) ||
|
||||
typeof source.commentId !== "string" || !uuid.test(source.commentId)) continue;
|
||||
const [original] = await db.select({ filename: assets.originalFilename, byteSize: assets.byteSize, sha256: assets.sha256 })
|
||||
.from(issueAttachments).innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, companyId)))
|
||||
.where(and(eq(issueAttachments.id, source.attachmentId), eq(issueAttachments.companyId, companyId),
|
||||
eq(issueAttachments.issueCommentId, source.commentId))).limit(1);
|
||||
if (original?.filename === attachment.filename && original.byteSize === attachment.byteSize &&
|
||||
original.sha256.toLowerCase() === attachment.sha256.toLowerCase()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Recognize explicit output requests, not incidental mentions of source files.
|
||||
|
|
@ -94,8 +119,8 @@ export async function validateNativeDeliverableEvidence(
|
|||
// this run published the newly requested output. The receipt survives a
|
||||
// controller restart of this run; a replacement can re-register preserved
|
||||
// workspace bytes internally rather than asking the user to confirm them.
|
||||
if (fileRequested && (attachment.originatingRunId !== binding.runId ||
|
||||
!hasCurrentPublicationReceipt(binding.semanticToolReceipts, attachment))) {
|
||||
if (fileRequested && attachment.originatingRunId !== binding.runId) continue;
|
||||
if (fileRequested && !await hasCurrentPublicationReceipt(db, binding.companyId, binding.semanticToolReceipts, attachment)) {
|
||||
throw new Error("This attachment has no matching verified publication receipt for this run's requested output. Inspect any preserved file and use register_deliverable to verify its current filename, size, and SHA-256, then cite the new receipt. No human completion approval was created.");
|
||||
}
|
||||
registeredAttachment = true;
|
||||
|
|
|
|||
|
|
@ -1184,6 +1184,12 @@ describe("native runner file handoff", () => {
|
|||
// Feedback reloads the durable receipt, so a controller restart of the same run keeps this proof.
|
||||
await expect(nativeCompletionFeedback(db, nextRunId, doneReport([`deliverable:${current.entityRefs[0]}`])))
|
||||
.resolves.toContain("Completion report accepted");
|
||||
await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref, `deliverable:${current.entityRefs[0]}`])))
|
||||
.resolves.toContain("Completion report accepted");
|
||||
const [currentProduct] = await db.insert(issueWorkProducts).values({ companyId, issueId, type: "artifact", provider: "external",
|
||||
title: "Current report", status: "ready_for_review", url: "https://example.com/current.pdf", createdByRunId: nextRunId }).returning();
|
||||
await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref, `work_product:${currentProduct.id}`])))
|
||||
.resolves.toContain("Completion report accepted");
|
||||
} finally {
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
||||
await db.delete(issueWorkProducts).where(eq(issueWorkProducts.id, product.id));
|
||||
|
|
|
|||
Loading…
Reference in New Issue