mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
feat(evals): bind one production chat attachment to its human receipt
This commit is contained in:
1 parent
1be8e44467
commit
1c84384d41
9 files changed
+181
-8
No files matched your search
@@ -173,3 +173,11 @@ in the history. Across the original final-definition batch and this recheck,
|
||||
backlog is 5/5 and delegation is 5/6. The local
|
||||
`results/chat-feature-recheck/verification.md` records the follow-up evidence;
|
||||
its standard dashboard is explicitly scoped to the latest campaign.
|
||||
|
||||
## Full catalog campaign
|
||||
|
||||
The frozen denominator is 244 requests across 35 areas and 191 capabilities. The [durable progress record](../../doc/plans/2026-10-26-chat-catalog-eval-progress.md) and `chat-campaign-manifest.json` distinguish complete, partial, unimplemented, blocked and actual measured results. Discovery and oracle calibration are not live acceptance. Each canonical scenario has one initial attempt and at most one coherent correction/rerun, with automatic retries zero; failures and unrun scenarios remain in the denominator.
|
||||
|
||||
Domain descriptors declare real setup/actions/independent grading and exact bounds. `setupHumanMessageCount` defaults to zero; genuine earlier human turns and native setup runs remain in the declared total, with persisted comment/run IDs captured before the canonical query. No historical model reply is fabricated.
|
||||
|
||||
Only `steering.preserve-draft` and `tasks.attachment-brief` may currently declare one `queryAttachments` receipt. The canonical typed query remains unchanged; the production composer appends its exact attachment reference. Before and after send, the harness independently reads the public same-source receipt and actual bytes, verifies SHA-256 and human attribution, and binds it to the real canonical comment. Extra markdown, text changes, foreign receipts and hidden followups fail. Attachment fingerprints pin both production composer source hashes as data, without importing the UI graph.
|
||||
@@ -46,6 +46,14 @@ export class RunnerApi {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Fetch owned attachment bytes with the same authenticated actor as JSON reads. */
|
||||
async getAttachmentContent(attachmentId: string): Promise<Buffer> {
|
||||
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(attachmentId)) throw new Error("Invalid fixture attachment identity");
|
||||
const response = await this.request.get(`/api/attachments/${attachmentId}/content`, this.sessionOptions());
|
||||
if (!response.ok()) throw new RunnerApiHttpError(response.status(), await failureMessage(response, "GET"));
|
||||
return response.body();
|
||||
}
|
||||
|
||||
async post<T>(path: string, data?: unknown, options?: { timeout: number }): Promise<T> {
|
||||
const response = await this.request.post(path, { data, ...this.sessionOptions(), ...options });
|
||||
if (!response.ok()) throw new Error(await failureMessage(response, "POST"));
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface CampaignAttempt {
|
||||
automaticRetries: 0;
|
||||
correction: string | null;
|
||||
classification: string | null;
|
||||
diagnosisHistory?: Array<{ at: string; classification: string | null; detail: string | null; supersededBy: string }>;
|
||||
observedFailure: string | null;
|
||||
evidencePaths: string[];
|
||||
usage: { costUsd: number | null; coverage: string; tokens: number | null };
|
||||
@@ -105,7 +106,8 @@ export function reserveCampaignAttempt(scenario: CampaignScenario, attempt: Camp
|
||||
export function completeCampaignAttempt(scenario: CampaignScenario, completion: {
|
||||
outcome: CampaignOutcome; finishedAt: string; definitionDigest: string; graderDigest: string;
|
||||
evidencePaths: string[]; cleanup: CampaignAttempt["cleanup"]; usage: CampaignAttempt["usage"];
|
||||
classification: string | null; observedFailure: string | null; corePrDependencies: string[];
|
||||
classification: string | null;
|
||||
diagnosisHistory?: Array<{ at: string; classification: string | null; detail: string | null; supersededBy: string }>; observedFailure: string | null; corePrDependencies: string[];
|
||||
}): void {
|
||||
const attempt = scenario.attempts.at(-1);
|
||||
if (!attempt || attempt.outcome !== "running") throw new Error("No running reserved attempt");
|
||||
|
||||
@@ -26,6 +26,11 @@ export interface ChatCatalogQueryBinding {
|
||||
resourceKind: "issue";
|
||||
valueField: "identifier";
|
||||
}
|
||||
export interface ChatCatalogQueryAttachment {
|
||||
/** A genuine UI upload receipt returned by setup; never arbitrary body text. */
|
||||
stateKey: string;
|
||||
filename: string;
|
||||
}
|
||||
/** One concrete scenario, with production API setup and a case-specific independent oracle. */
|
||||
export interface ChatCatalogCase {
|
||||
scenarioId: string;
|
||||
@@ -36,6 +41,7 @@ export interface ChatCatalogCase {
|
||||
queryOutcome?: "persisted" | "rejected";
|
||||
/** Audited substitution of an incidental catalog fixture token, never other prose. */
|
||||
queryBindings?: readonly ChatCatalogQueryBinding[];
|
||||
queryAttachments?: readonly ChatCatalogQueryAttachment[];
|
||||
/** All case/grader helpers must be listed; the suite also fingerprints its complete source tree. */
|
||||
sourceFiles: readonly string[];
|
||||
oracleVersion: string;
|
||||
|
||||
@@ -93,16 +93,20 @@ export function chatCatalogCaseFingerprint(descriptor: ChatCatalogDescriptor, se
|
||||
.find((row: { id: string }) => row.id === descriptor.scenarioId);
|
||||
if (!scenario) throw new Error(`Missing frozen scenario: ${descriptor.scenarioId}`);
|
||||
const { id, capabilities, context, query, expected_outcome } = scenario;
|
||||
return fingerprintChatDependencyClosure({ root,
|
||||
const attachmentFormatter = descriptor.queryAttachments?.length ? ["ui/src/components/IssueChatThread.tsx", "ui/src/components/task-chat/TaskChatComposer.tsx"].map(file => ({
|
||||
file, sha256: hash(readFileSync(new URL(`../../${file}`, import.meta.url), "utf8")),
|
||||
})) : null;
|
||||
const result = fingerprintChatDependencyClosure({ root,
|
||||
entries: [selectedModule, "tests/runner-e2e/chat-catalog-flow.ts", "tests/runner-e2e/chat-catalog-fingerprint.ts", "tests/runner-e2e/chat-catalog-dynamic-resolutions.json",
|
||||
"tests/runner-e2e/chat-feature-flow.ts", "tests/runner-e2e/runner.spec.ts", "tests/runner-e2e/launch.ts",
|
||||
"tests/runner-e2e/chat-scenarios/defaults.yaml", ...descriptor.sourceFiles.map(file => `tests/runner-e2e/${file}`)],
|
||||
selected: { descriptor, selectedExecution, lane, scenario: { id, capabilities, context, query, expected_outcome } },
|
||||
selected: { descriptor, selectedExecution, lane, attachmentFormatter, scenario: { id, capabilities, context, query, expected_outcome } },
|
||||
reviewedDynamicResolutions,
|
||||
selectedDispatchFile: "tests/runner-e2e/chat-catalog-registry.ts",
|
||||
normalizedMetadata: { "tests/runner-e2e/chat-catalog-descriptors.json": descriptor,
|
||||
"tests/runner-e2e/chat-campaign-manifest.json": { id, capabilities, context, query, expected_outcome } },
|
||||
});
|
||||
return { ...result, attachmentFormatter };
|
||||
}
|
||||
|
||||
export function normalizedChatCatalogExecution(execution: MatrixExecution) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chatQueryWithAttachments, resolveChatQueryAttachments, verifyChatQueryAttachments, type ChatQueryAttachmentReceipt } from "./chat-query-attachments.js";
|
||||
import { chatCatalogAllowedRunStatuses, gradeChatCatalogRuns } from "./chat-catalog-run-policy.js";
|
||||
import { chatCatalogCaseFingerprint, normalizedChatCatalogExecution } from "./chat-catalog-fingerprint.js";
|
||||
import { expect } from "@playwright/test";
|
||||
@@ -21,12 +22,14 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
|
||||
let grade: ChatCatalogGrade | undefined;
|
||||
let failed = false;
|
||||
let renderedQuery = scenario.query;
|
||||
let persistedQuery = scenario.query;
|
||||
let queryAttachments: ChatQueryAttachmentReceipt[] = [];
|
||||
let setupBoundary: ChatCatalogSetupBoundary = { humanCommentIds: [], runIds: [] };
|
||||
async function conversation() {
|
||||
await context.refreshIssue();
|
||||
const issue = context.issue();
|
||||
const runs = await context.allRuns();
|
||||
const comments = issue ? await input.api.get<Array<{ id: string; body: string; authorAgentId?: string; createdByRunId?: string }>>(
|
||||
const comments = issue ? await input.api.get<Array<{ id: string; body: string; authorAgentId?: string; authorUserId?: string; issueId?: string; companyId?: string; createdByRunId?: string }>>(
|
||||
`/api/issues/${issue.id}/comments?order=asc`,
|
||||
) : [];
|
||||
const renderedLinks = [];
|
||||
@@ -44,11 +47,17 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
|
||||
observation = await implementation!.observe(context, state!);
|
||||
grade = validateChatCatalogGrade(implementation!.grade(observation));
|
||||
const human = evidence.comments.filter(comment => !comment.authorAgentId);
|
||||
const canonicalHuman = human[implementation!.setupHumanMessageCount ?? 0];
|
||||
const attachmentsValid = !queryAttachments.length || !!canonicalHuman && await verifyChatQueryAttachments(context, queryAttachments, canonicalHuman);
|
||||
if (queryAttachments.length) await input.evidence("chat-catalog-query-attachment-receipts.json", { receipts: queryAttachments,
|
||||
canonicalCommentId: canonicalHuman?.id ?? null, verified: attachmentsValid });
|
||||
const checks = [...grade.checks,
|
||||
{ id: "catalog-complete-conditions", passed: !implementation!.coverageGaps?.length,
|
||||
detail: implementation!.coverageGaps?.length ? `Missing conditions: ${implementation!.coverageGaps.join(", ")}` : "No known catalog condition is omitted." },
|
||||
{ id: "catalog-original-human-query", passed: chatCatalogHumanHistoryMatches({ implementation: implementation!, human, runIds: evidence.runs.map(run => run.id), boundary: setupBoundary, renderedQuery }),
|
||||
{ id: "catalog-original-human-query", passed: chatCatalogHumanHistoryMatches({ implementation: implementation!, human, runIds: evidence.runs.map(run => run.id), boundary: setupBoundary, renderedQuery: persistedQuery }),
|
||||
detail: "The persisted request preserves the catalog query with only audited public-fixture identity bindings; all human turns were declared." },
|
||||
{ id: "catalog-query-attachment-receipts", passed: attachmentsValid,
|
||||
detail: "Only declared production attachment suffixes are allowed, backed by same-source human receipts and independently retrieved matching bytes." },
|
||||
...gradeChatCatalogRuns({ implementation: implementation!, companyId: input.fixtures.company.id,
|
||||
leadId: input.fixtures.agent.id, source: evidence.issue, runs: evidence.runs }).checks,
|
||||
];
|
||||
@@ -63,6 +72,11 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
|
||||
state = await implementation.setup(context);
|
||||
const query = await resolveChatCatalogQuery(context, implementation.queryBindings, state);
|
||||
renderedQuery = query.renderedQuery;
|
||||
if (implementation.queryOutcome === "rejected" && implementation.queryAttachments?.length) throw new Error("Rejected-query attachment branches need an explicit contract");
|
||||
queryAttachments = await resolveChatQueryAttachments(context, implementation.queryAttachments, state);
|
||||
persistedQuery = chatQueryWithAttachments(renderedQuery, queryAttachments);
|
||||
if (queryAttachments.length) await input.evidence("chat-catalog-query-attachments-before-send.json", {
|
||||
originalQuery: scenario.query, renderedQuery, persistedQuery, receipts: queryAttachments });
|
||||
await input.evidence("chat-catalog-query-binding.json", { scenarioId: scenario.id, ...query });
|
||||
const company = `/api/companies/${input.fixtures.company.id}`;
|
||||
const agents = await input.api.get<Array<{ id: string }>>(`${company}/agents`);
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function loadChatCatalogImplementation(
|
||||
const selected = implementations.filter((row: ChatCatalogCase) => row.scenarioId === descriptor.scenarioId);
|
||||
if (selected.length !== 1) throw new Error(`Missing or duplicate runtime case: ${descriptor.scenarioId}`);
|
||||
const implementation = selected[0] as ChatCatalogCase;
|
||||
for (const key of ["sourceFiles", "oracleVersion", "expectedRunCount", "minimumRunCount", "expectedHumanMessageCount", "setupHumanMessageCount", "turnTimeoutMs", "attemptTimeoutMs", "coverageGaps", "journeyKind", "authentication", "setupPolicy", "runPolicy", "queryOutcome", "queryBindings"] as const) {
|
||||
for (const key of ["sourceFiles", "oracleVersion", "expectedRunCount", "minimumRunCount", "expectedHumanMessageCount", "setupHumanMessageCount", "turnTimeoutMs", "attemptTimeoutMs", "coverageGaps", "journeyKind", "authentication", "setupPolicy", "runPolicy", "queryOutcome", "queryBindings", "queryAttachments"] as const) {
|
||||
if (JSON.stringify(implementation[key]) !== JSON.stringify(descriptor[key])) throw new Error(`Runtime descriptor drift: ${descriptor.scenarioId}.${key}`);
|
||||
}
|
||||
if ((descriptor.setupPolicy?.funding === "preserve-scenario" || descriptor.setupPolicy?.readiness === "preserve-scenario")
|
||||
@@ -69,10 +69,10 @@ export async function loadIdentityBoundaryImplementation(descriptor: ChatCatalog
|
||||
const selected = Array.isArray(rows) ? rows.filter(row => row.scenarioId === descriptor.scenarioId) : [];
|
||||
if (selected.length !== 1) throw new Error("Missing or duplicate browser boundary implementation");
|
||||
const implementation = selected[0] as IdentityBoundaryCase;
|
||||
for (const key of ["sourceFiles", "oracleVersion", "expectedRunCount", "minimumRunCount", "expectedHumanMessageCount", "setupHumanMessageCount", "turnTimeoutMs", "attemptTimeoutMs", "coverageGaps", "journeyKind", "boundaryTarget", "authentication", "setupPolicy", "runPolicy", "queryOutcome", "queryBindings"] as const) {
|
||||
for (const key of ["sourceFiles", "oracleVersion", "expectedRunCount", "minimumRunCount", "expectedHumanMessageCount", "setupHumanMessageCount", "turnTimeoutMs", "attemptTimeoutMs", "coverageGaps", "journeyKind", "boundaryTarget", "authentication", "setupPolicy", "runPolicy", "queryOutcome", "queryBindings", "queryAttachments"] as const) {
|
||||
if (JSON.stringify(implementation[key]) !== JSON.stringify(descriptor[key])) throw new Error(`Runtime boundary descriptor drift: ${descriptor.scenarioId}.${key}`);
|
||||
}
|
||||
if ([implementation.run, implementation.grade, implementation.cleanup].some(value => typeof value !== "function") || descriptor.queryBindings?.length
|
||||
if ([implementation.run, implementation.grade, implementation.cleanup].some(value => typeof value !== "function") || descriptor.queryBindings?.length || descriptor.queryAttachments?.length
|
||||
|| !["authenticated-harness", "empty-installed-target"].includes(String(descriptor.boundaryTarget)))
|
||||
throw new Error("Boundary requires executable outcome, independent grade and cleanup; query bindings are Agent Chat only");
|
||||
return implementation;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect, it } from "vitest";
|
||||
import { attachmentReceiptMatches, chatQueryWithAttachments, resolveChatQueryAttachments, type ChatQueryAttachmentReceipt } from "./chat-query-attachments.js";
|
||||
import { chatCatalogHumanHistoryMatches, type ChatCatalogContext } from "./chat-catalog-contract.js";
|
||||
const id = "11111111-1111-4111-8111-111111111111", bytes = Buffer.from("Synthetic website brief");
|
||||
const receipt: ChatQueryAttachmentReceipt = { id, companyId: "company", issueId: "source", issueCommentId: null,
|
||||
originalFilename: "website-brief.md", contentPath: `/api/attachments/${id}/content`, byteSize: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"), createdByAgentId: null, createdByUserId: "human", originatingRunId: null };
|
||||
const base = { expected: receipt, saved: receipt, bytes, companyId: "company", issueId: "source" };
|
||||
it("allows only exact production attachment suffixes after the unchanged canonical query", () => {
|
||||
const query = "Have Morgan turn the attached brief into a website.";
|
||||
const body = chatQueryWithAttachments(query, [receipt]);
|
||||
expect(body).toBe(`${query}\n\n[website-brief.md](${receipt.contentPath})`);
|
||||
expect(chatQueryWithAttachments(query, [])).toBe(query);
|
||||
expect(() => chatQueryWithAttachments(query, [receipt, receipt])).toThrow("Only one");
|
||||
expect(chatQueryWithAttachments(query, [{ ...receipt, originalFilename: "brief[1].md" }])).toContain("[brief\\[1\\].md]");
|
||||
const history = { implementation: {}, human: [{ id: "comment", body }], runIds: [], boundary: { humanCommentIds: [], runIds: [] }, renderedQuery: body };
|
||||
expect(chatCatalogHumanHistoryMatches(history)).toBe(true);
|
||||
expect(chatCatalogHumanHistoryMatches({ ...history, human: [{ id: "comment", body: body + "\nPlease finish" }] })).toBe(false);
|
||||
expect(chatCatalogHumanHistoryMatches({ ...history, human: [{ id: "comment", body: body.replace(query, "Another query") }] })).toBe(false);
|
||||
});
|
||||
it("requires genuine same-source human upload, fetched content hashes and canonical-comment attribution", () => {
|
||||
expect(attachmentReceiptMatches(base)).toBe(true);
|
||||
const comment = { id: "canonical", body: "query", authorUserId: "human", authorAgentId: null, createdByRunId: null, issueId: "source", companyId: "company" };
|
||||
const saved = { ...receipt, issueCommentId: "canonical" };
|
||||
expect(attachmentReceiptMatches({ ...base, saved, comment })).toBe(true);
|
||||
for (const patch of [{ companyId: "foreign" }, { issueId: "other-source" }, { contentPath: "https://outside.example/file" },
|
||||
{ originalFilename: "wrong.md" }, { sha256: "0".repeat(64) }, { createdByAgentId: "agent" }, { originatingRunId: "run" },
|
||||
{ createdByUserId: "other-human" }, { byteSize: 0 }]) expect(attachmentReceiptMatches({ ...base, saved: { ...receipt, ...patch } })).toBe(false);
|
||||
expect(attachmentReceiptMatches({ ...base, bytes: Buffer.from("wrong bytes") })).toBe(false);
|
||||
expect(attachmentReceiptMatches({ ...base, saved: { ...saved, issueCommentId: "other-comment" }, comment })).toBe(false);
|
||||
expect(attachmentReceiptMatches({ ...base, saved, comment: { ...comment, authorUserId: "other-human" } })).toBe(false);
|
||||
});
|
||||
it("independently resolves declared receipt IDs and rejects undeclared, duplicate or forged uploads", async () => {
|
||||
const ctx = { scenario: { id: "steering.preserve-draft" }, refreshIssue: async () => {}, issue: () => ({ id: "source" }),
|
||||
input: { fixtures: { company: { id: "company" } }, api: { get: async () => [receipt], getAttachmentContent: async (attachmentId: string) => { expect(attachmentId).toBe(id); return bytes; } } } } as unknown as ChatCatalogContext;
|
||||
const spec = { stateKey: "brief", filename: "website-brief.md" };
|
||||
expect(await resolveChatQueryAttachments(ctx, [spec], { brief: receipt })).toEqual([receipt]);
|
||||
await expect(resolveChatQueryAttachments({ ...ctx, scenario: { ...ctx.scenario, id: "goals.create-goal-tree" } }, [spec], { brief: receipt })).rejects.toThrow("Undeclared");
|
||||
await expect(resolveChatQueryAttachments(ctx, [spec, spec], { brief: receipt })).rejects.toThrow("duplicate");
|
||||
await expect(resolveChatQueryAttachments(ctx, [spec], { brief: { ...receipt, sha256: "0".repeat(64) } })).rejects.toThrow("hash");
|
||||
await expect(resolveChatQueryAttachments(ctx, [spec], {})).rejects.toThrow("persisted UI upload");
|
||||
});
|
||||
|
||||
it("matches the actual production submittedBody formatter without loading its UI module", () => {
|
||||
const source = readFileSync(new URL("../../ui/src/components/IssueChatThread.tsx", import.meta.url), "utf8");
|
||||
const expression = source.match(/const submittedBody = \[\s*trimmed,\s*\.\.\.attachedFiles\.map\(\s*\(item\) =>\s*(`[^`]+`),/);
|
||||
expect(expression).not.toBeNull();
|
||||
const production = new Function("trimmed", "attachedFiles", `return [trimmed,...attachedFiles.map(item => ${expression![1]})].filter(Boolean).join("\\n\\n")`) as (query: string, attachments: Array<{ name: string; contentPath: string }>) => string;
|
||||
for (const name of ["website-brief.md", "brief[1].md", "brief (draft).md"]) {
|
||||
expect(chatQueryWithAttachments("Original query", [{ ...receipt, originalFilename: name }])).toBe(production("Original query", [{ name, contentPath: receipt.contentPath }]));
|
||||
}
|
||||
});
|
||||
|
||||
it("also matches the active TaskChatComposer for its one declared upload", () => {
|
||||
const source = readFileSync(new URL("../../ui/src/components/task-chat/TaskChatComposer.tsx", import.meta.url), "utf8");
|
||||
const escapeBody = source.match(/function escapeMarkdownLabel\(name: string\): string \{\s*([^}]+)\}/)![1]!;
|
||||
const escape = new Function("name", escapeBody);
|
||||
const template = source.match(/const refLines = attachedRefs\s*\.map\(\(item\) => (`[^`]+`)\)/)![1]!;
|
||||
const production = new Function("trimmed", "attachedRefs", "escapeMarkdownLabel", `return [trimmed,attachedRefs.map(item => ${template}).join("\\n")].filter(Boolean).join("\\n\\n")`) as (query: string, refs: Array<{ name: string; contentPath: string }>, escape: Function) => string;
|
||||
for (const name of ["website-brief.md", "brief[1].md"]) expect(chatQueryWithAttachments("Canonical query", [{ ...receipt, originalFilename: name }])).toBe(production("Canonical query", [{ name, contentPath: receipt.contentPath }], escape));
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ChatCatalogContext, ChatCatalogQueryAttachment } from "./chat-catalog-contract.js";
|
||||
|
||||
export interface ChatQueryAttachmentReceipt {
|
||||
id: string; companyId: string; issueId: string; issueCommentId: string | null;
|
||||
originalFilename: string | null; contentPath: string; byteSize: number; sha256: string;
|
||||
createdByAgentId: string | null; createdByUserId: string | null; originatingRunId?: string | null;
|
||||
}
|
||||
export interface ChatQueryHumanComment {
|
||||
id: string; body: string; authorAgentId?: string | null; authorUserId?: string | null;
|
||||
issueId?: string; companyId?: string; createdByRunId?: string | null;
|
||||
}
|
||||
const permitted = new Set(["steering.preserve-draft", "tasks.attachment-brief"]);
|
||||
function receiptProjection(row: ChatQueryAttachmentReceipt): ChatQueryAttachmentReceipt {
|
||||
return { id: row.id, companyId: row.companyId, issueId: row.issueId, issueCommentId: row.issueCommentId,
|
||||
originalFilename: row.originalFilename, contentPath: row.contentPath, byteSize: row.byteSize, sha256: row.sha256,
|
||||
createdByAgentId: row.createdByAgentId, createdByUserId: row.createdByUserId, originatingRunId: row.originatingRunId };
|
||||
}
|
||||
const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
||||
/** Exact IssueChatThread submittedBody formatter; no alternate markdown or body rewriting. */
|
||||
export function chatQueryWithAttachments(query: string, receipts: readonly ChatQueryAttachmentReceipt[]): string {
|
||||
if (receipts.length > 1) throw new Error("Only one declared production attachment is supported");
|
||||
return [query.trim(), ...receipts.map(item => `[${item.originalFilename!.replace(/[[\]]/g, "\\$&")}](${item.contentPath})`)].filter(Boolean).join("\n\n");
|
||||
}
|
||||
export function attachmentReceiptMatches(input: {
|
||||
expected: ChatQueryAttachmentReceipt; saved: ChatQueryAttachmentReceipt; bytes: Uint8Array;
|
||||
companyId: string; issueId: string; comment?: ChatQueryHumanComment;
|
||||
}): boolean {
|
||||
const { expected, saved, bytes, companyId, issueId, comment } = input;
|
||||
return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(saved.id)
|
||||
&& saved.id === expected.id && saved.companyId === companyId && saved.issueId === issueId
|
||||
&& saved.originalFilename === expected.originalFilename && !!saved.originalFilename
|
||||
&& saved.contentPath === `/api/attachments/${saved.id}/content` && saved.contentPath === expected.contentPath
|
||||
&& typeof saved.createdByUserId === "string" && !!saved.createdByUserId && saved.createdByUserId === expected.createdByUserId
|
||||
&& saved.createdByAgentId == null && saved.originatingRunId == null
|
||||
&& /^[a-f0-9]{64}$/.test(saved.sha256) && saved.sha256 === expected.sha256
|
||||
&& digest(bytes) === saved.sha256 && saved.byteSize === bytes.byteLength && saved.byteSize === expected.byteSize
|
||||
&& (comment ? saved.issueCommentId === comment.id && comment.authorUserId === saved.createdByUserId
|
||||
&& comment.authorAgentId == null && comment.createdByRunId == null && comment.issueId === issueId && comment.companyId === companyId
|
||||
: saved.issueCommentId == null);
|
||||
}
|
||||
export async function resolveChatQueryAttachments(ctx: ChatCatalogContext, specs: readonly ChatCatalogQueryAttachment[] = [], state: Record<string, unknown>) {
|
||||
if (!specs.length) return [];
|
||||
if (!permitted.has(ctx.scenario.id) || specs.length !== 1 || new Set(specs.map(row => row.stateKey)).size !== specs.length) throw new Error("Undeclared or duplicate catalog query attachment");
|
||||
await ctx.refreshIssue(); const issue = ctx.issue(); if (!issue) throw new Error("UI upload has no persisted source issue");
|
||||
const saved = await ctx.input.api.get<ChatQueryAttachmentReceipt[]>(`/api/issues/${issue.id}/attachments`);
|
||||
const receipts: ChatQueryAttachmentReceipt[] = [];
|
||||
for (const spec of specs) {
|
||||
const expected = state[spec.stateKey] as ChatQueryAttachmentReceipt | undefined;
|
||||
const matches = saved.filter(row => row.id === expected?.id);
|
||||
if (!expected || matches.length !== 1 || expected.originalFilename !== spec.filename || !spec.filename.trim() || /[\r\n\0]/.test(spec.filename)
|
||||
|| receipts.some(row => row.id === expected.id)) throw new Error("Declared attachment differs from the persisted UI upload");
|
||||
const receipt = matches[0]!; const bytes = await ctx.input.api.getAttachmentContent(receipt.id);
|
||||
if (!attachmentReceiptMatches({ expected, saved: receipt, bytes, companyId: ctx.input.fixtures.company.id, issueId: issue.id })) throw new Error("Attachment source, content hash or human upload attribution does not match");
|
||||
receipts.push(receiptProjection(receipt));
|
||||
}
|
||||
return receipts;
|
||||
}
|
||||
export async function verifyChatQueryAttachments(ctx: ChatCatalogContext, receipts: readonly ChatQueryAttachmentReceipt[], comment: ChatQueryHumanComment) {
|
||||
const issue = ctx.issue(); if (!issue) return false;
|
||||
const saved = await ctx.input.api.get<ChatQueryAttachmentReceipt[]>(`/api/issues/${issue.id}/attachments`);
|
||||
for (const expected of receipts) {
|
||||
const matches = saved.filter(row => row.id === expected.id); if (matches.length !== 1) return false;
|
||||
const receipt = matches[0]!; const bytes = await ctx.input.api.getAttachmentContent(receipt.id);
|
||||
if (!attachmentReceiptMatches({ expected, saved: receipt, bytes, companyId: ctx.input.fixtures.company.id, issueId: issue.id, comment })) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user