Files
PaperClipAI/server/src/services/native-runtime/chat-attachment-reuse.test.ts
T
df6156c2e3 fix(server): wake native status reassessment from committed changes (#17709)
## Thinking Path

> - Paperclip manages agents, tasks and their durable native results.
> - Changed evidence, task authority or review retirement can require a
new status assessment.
> - Periodic orphan recovery repeatedly reads history to find these
changes.
> - This change gives reassessment an owner driven by committed changes
and saved deadlines.
> - Existing result, assessment, decision and task rows remain durable
truth.
> - The status committer still verifies current sources and authority
before writing.
> - Settled or insufficient evidence becomes quiet until a supported
producer changes it.

## Linked Issues or Issue Description

Refs #17702 (merged), #17679 and #17680. This is slice 50, now based on
master. It owns native status reassessment; workspace settlement,
retained sessions and other orphan duties remain separate.

**What happened?**

Periodic recovery repeatedly inspects completed native results. Removing
that scan without complete writer coverage or current authority checks
could strand new evidence or commit an old result over newer work.

**Expected behavior**

Reassess from existing durable facts after relevant outer transactions
commit. Retain real retry/lease deadlines and restart/drain recovery.
Preserve current authority, quality-review and supersession checks. Make
no recurring SQL when work is settled or dormant.

**Steps to reproduce**

Complete a native result awaiting evidence or review, leave it idle,
then approve a real work product or retire the selected automatic
review. Its saved result should advance once when authorized, or record
the existing nonterminal business gate, without a periodic scan.

## What Changed

- Extract reassessment and its pure status policy from finalization
recovery into their owning modules. Add a scoped worker using the
existing delivery coordinator and scheduler; no queue table or migration
is added.
- Retain the three durable discovery triggers: changed work-product
history, authoritative task status/version, or retirement of the
selected automatic completion review. Preserve selected-source,
ownership, effects, retry and lease fences under the existing committer.
- Notify from actual task/work-product, claim release, approval,
interaction, OAuth, event evidence and workspace-settlement owners
before their transaction writes. Keep provider calls outside short
transactions and nested writes behind outer commit.
- Scope ordinary milestone/finalization/coalesced-input wakes to an
issue/run. Keep genuine company authority restoration explicit. Real
Photon and coalescer regressions cover all three response/status queues.
- Move the current parent's required quality policy into the owner and
apply it to every reassessment that could complete a task. An unmet
review records the existing in-progress decision and next action,
avoiding an operational retry loop. Preserve conversation/session reset
locks and the workspace omission-barrier signal.
- Skip reassessment in periodic recovery while preserving
explicit/startup recovery and the historical supersession guard before
independent workspace work. Document the source contract, producer
inventory, failures and qualifications in the linked audit.

## Verification

- Broad source `9fddb3f0b1157df32b64fb1f22e9d6e9624470e8` passes **2731
distinct cases in 28 complete files**, with no skips: 2458
producer/owner/authority/workspace/quality cases in 394.43 seconds and
all 273 governed-response cases in 53.74 seconds. Nineteen dedicated
fresh PostgreSQL 17 databases migrate through 0413; eighteen
embedded-only invocations preserve fresh-per-call isolation through the
existing recovery helper. All 22 temporary imports restore exactly and
all 28 hashes match.
- Final runtime source `5ea3b0fd016bf7e63ba82a7044825a46a0cd2082` passes
**1211 cases in five complete affected files** in 108.23 seconds after
the narrowly reproduced evidence-trigger fix: all 43 worker, 145 corpus,
32 finalization-recovery, 968 native-session and 23 quality-review
cases. Four fresh databases plus the quality helper's own fresh fixture
use PostgreSQL 17. Its one adapted import restores exactly; all five
hashes match. The other 27 broad-run test files are unchanged. These
qualifications are source-scoped and not cumulatively double-counted.
- Required/optional real regressions reproduce retry loops on
retired-review and evidence-approval paths before repair; both become
SQL quiet afterward with unchanged assertions/time limits. Controlled
removal of only ordinary scope guards reproduces two coarse-hint SQL
failures; exact guard restoration passes. Real producer, outer
transaction, claim, authority, 103-item backlog, retry and drain cases
pass. Photon transport is mocked; callbacks, locks and observed SQL are
real.
- Normal merges preserve 12661 parent/unrelated entries, 35 owned
entries and 21 automatic shared results, with five original conflicts
reviewed explicitly. The parent's actual squash reconciliation preserves
the entire held child tree. The final quality fix changes only its owner
and tests. Fourteen added comments are removed with identical nontrivia
tokens; no directives are removed and the final comment check is empty.
- Database build, server types/build, boundaries and exact-head
migration order pass. The database tree is identical to merged #17702:
no new SQL, queue table or snapshot is generated. Earlier benchmarks,
host limitations, failed assertions and their repairs remain in
`doc/plans/2026-10-26-native-status-reassessment-sleep-audit.md` and
`doc/idle-native-status-reassessment.md`.
- This qualifies targeted external PostgreSQL behavior, not embedded
bootstrap on the semaphore-exhausted host, a full local repository
suite, deployed sleep or measured fleet savings. Fresh exact-head CI and
review remain required.

## Risks

- Notifications retain the process-local supported-writer contract.
Independent SQL writers and uncoordinated replicas are not covered by a
new transport.
- Reassessment competes with current authority changes. Existing
row/source locks and retries remain required; arbitrary missing
identities have no row to lock, so this is not a general predicate-lock
guarantee.
- Discovery pages 100 rows but retains outstanding candidates in memory.
It remains proportional to backlog size, and cancellation occurs between
queries.
- Other orphan/workspace/session duties and Cloud sleep eligibility
remain unchanged. Missing supported producer coverage could strand
dormant work, so the producer audit and real callback/transaction tests
remain part of acceptance.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, shell
execution and PostgreSQL tests. The runtime does not expose a more
specific serving snapshot or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (targeted scope is stated
above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Codex <noreply@openai.com>
2026-10-29 03:40:01 -05:00

1275 lines
45 KiB
TypeScript

import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { mkdir, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
assets,
chatConversations,
chatDeliveries,
chatEndpointResources,
chatEndpoints,
chatExternalPrincipals,
chatIdentityLinks,
chatMessageLinks,
chatPublications,
companies,
companyMemberships,
createDb,
heartbeatRuns,
issueAttachments,
issueComments,
issues,
issueWorkProducts,
toolApplications,
toolConnections,
} from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
import { createStorageService } from "../../storage/service.js";
import type { StorageService } from "../../storage/types.js";
import { issueService } from "../issues.js";
import { mergeHeartbeatRunResultJson } from "../heartbeat-run-summary.js";
import {
authorizeChatConversationForBoundRun,
prepareReusedChatAttachment,
resolveExternalChatResponseWaitAuthorization,
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<
ReturnType<typeof startEmbeddedPostgresTestDatabase>
> | null = null;
let db: ReturnType<typeof createDb>;
let storage: ReturnType<typeof createStorageService>;
const companyId = "10000000-0000-4000-8000-000000000101";
const agentId = "10000000-0000-4000-8000-000000000102";
const issueId = "10000000-0000-4000-8000-000000000103";
const runId = "10000000-0000-4000-8000-000000000104";
const endpointId = "10000000-0000-4000-8000-000000000105";
const conversationId = "10000000-0000-4000-8000-000000000106";
const resourceId = "10000000-0000-4000-8000-000000000107";
const principalId = "10000000-0000-4000-8000-000000000108";
const userId = "chat-user-1";
let currentCommentId: string;
let sourceCommentId: string;
let sourceAttachmentId: string;
const sourceBody = Buffer.from(
"same-conversation historical bytes\n",
"utf8",
);
beforeAll(async () => {
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_CHAT_REUSE_TEST_DATABASE_URL");
if (!external) temporary = await startEmbeddedPostgresTestDatabase("native-chat-reuse-");
db = createDb(external ?? temporary!.connectionString);
const storageRoot = await mkdtemp(
path.join(tmpdir(), "paperclip-chat-reuse-"),
);
await mkdir(storageRoot, { recursive: true });
storage = createStorageService(createLocalDiskStorageProvider(storageRoot));
await db.insert(companies).values({
id: companyId,
name: "Native chat attachment reuse",
issuePrefix: "NCR",
issueCounter: 1,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Native chat agent",
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex" },
runtimeConfig: {},
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
issueNumber: 1,
identifier: "NCR-1",
title: "Reuse the earlier file",
status: "in_progress",
workMode: "standard",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: issueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: {},
});
await db
.update(issues)
.set({ executionRunId: runId })
.where(eq(issues.id, issueId));
const applicationId = randomUUID();
const connectionId = randomUUID();
await db.insert(toolApplications).values({
id: applicationId,
companyId,
applicationKey: `chat:discord:${endpointId}`,
name: "Discord reuse",
type: "chat",
status: "active",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId,
applicationId,
name: "Discord reuse",
uid: `chat-discord-${endpointId}`,
connectionPurpose: "channel",
transport: "chat_sdk",
status: "active",
enabled: true,
});
await db.insert(chatEndpoints).values({
id: endpointId,
companyId,
connectionId,
provider: "discord",
publicId: randomUUID(),
assignedAgentId: agentId,
status: "active",
providerAccountId: "guild-1",
allowUnlinkedPeople: false,
});
await db.insert(chatEndpointResources).values({
id: resourceId,
companyId,
endpointId,
type: "channel",
providerResourceId: "channel-1",
label: "#files",
availability: "available",
enabled: true,
});
await db.insert(chatConversations).values({
id: conversationId,
companyId,
endpointId,
resourceId,
issueId,
externalConversationId: "channel-1",
externalThreadId: "thread-1",
externalLabel: "#files thread",
state: "active",
});
await db.insert(chatExternalPrincipals).values({
id: principalId,
companyId,
provider: "discord",
providerAccountId: "guild-1",
externalId: "discord-user-1",
kind: "user",
});
await db.insert(chatIdentityLinks).values({
companyId,
endpointId,
principalId,
paperclipUserId: userId,
status: "linked",
});
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "member",
});
const sourceComment = await issueService(db).addComment(
issueId,
"The earlier upload",
{ userId },
);
sourceCommentId = sourceComment.id;
const stored = await storage.putFile({
companyId,
namespace: `issues/${issueId}`,
originalFilename: "earlier.txt",
contentType: "text/plain",
body: sourceBody,
});
const sourceAttachment = await issueService(db).createAttachment({
issueId,
issueCommentId: sourceCommentId,
provider: stored.provider,
objectKey: stored.objectKey,
contentType: stored.contentType,
byteSize: stored.byteSize,
sha256: stored.sha256,
originalFilename: stored.originalFilename,
createdByUserId: userId,
});
sourceAttachmentId = sourceAttachment.id;
const sourceDeliveryId = randomUUID();
await db.insert(chatDeliveries).values({
id: sourceDeliveryId,
companyId,
endpointId,
conversationId,
principalId,
providerEventId: "source-event",
deduplicationKey: "source-event",
eventKind: "message",
normalizedEvent: {},
state: "processed",
attempts: 1,
processedAt: new Date(),
});
await db.insert(chatMessageLinks).values({
companyId,
endpointId,
conversationId,
deliveryId: sourceDeliveryId,
commentId: sourceCommentId,
providerMessageId: "source-message",
direction: "inbound",
});
const currentComment = await issueService(db).addComment(
issueId,
"Please send that earlier file again",
{ userId },
);
currentCommentId = currentComment.id;
const currentDeliveryId = randomUUID();
await db.insert(chatDeliveries).values({
id: currentDeliveryId,
companyId,
endpointId,
conversationId,
principalId,
providerEventId: "current-event",
deduplicationKey: "current-event",
eventKind: "message",
normalizedEvent: {},
state: "processed",
attempts: 1,
processedAt: new Date(),
});
await db.insert(chatMessageLinks).values({
companyId,
endpointId,
conversationId,
deliveryId: currentDeliveryId,
commentId: currentCommentId,
providerMessageId: "current-message",
direction: "inbound",
});
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
source: "chat:discord",
paperclipHarnessCheckedOut: true,
wakeCommentIds: [currentCommentId],
commentId: currentCommentId,
paperclipWake: {
reason: "External chat message received",
externalChatProvider: "discord",
checkedOutByHarness: true,
issue: { id: issueId, workMode: "standard" },
commentIds: [currentCommentId],
},
},
})
.where(eq(heartbeatRuns.id, runId));
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await temporary?.cleanup();
});
function authority() {
return new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
executionTargetKind: "remote",
storage,
});
}
it("bounds current authorization reads without loading unrelated chat content", async () => {
const binding = { companyId, agentId, issueId, runId };
const [run] = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
const [conversation] = await db.select().from(chatConversations).where(eq(chatConversations.id, conversationId));
const [resource] = await db.select().from(chatEndpointResources).where(eq(chatEndpointResources.id, resourceId));
const deliveries = await db.select().from(chatDeliveries).where(eq(chatDeliveries.endpointId, endpointId));
const bulk = randomBytes(64 * 1024).toString("base64");
const events: Array<Record<string, unknown>> = [];
let capture: ReturnType<typeof createDatabaseResponseCapture> | undefined;
try {
await db.update(chatEndpoints).set({ communicationInstructions: bulk,
setup: { ...endpoint.setup, unusedContent: bulk } as typeof endpoint.setup,
}).where(eq(chatEndpoints.id, endpointId));
await db.update(chatConversations).set({ communicationGuidance: bulk }).where(eq(chatConversations.id, conversationId));
await db.update(chatEndpointResources).set({ metadata: { ...resource.metadata, unusedContent: bulk } })
.where(eq(chatEndpointResources.id, resourceId));
for (const delivery of deliveries) {
await db.update(chatDeliveries).set({ normalizedEvent: { ...delivery.normalizedEvent, unusedContent: bulk } })
.where(eq(chatDeliveries.id, delivery.id));
}
const now = Date.now();
capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { chatMessageLinks, chatDeliveries, chatConversations, chatEndpoints,
chatEndpointResources, chatExternalPrincipals, chatIdentityLinks, companyMemberships }, capture);
for (const lockMode of ["read", "blocking", "nonblocking"] as const) {
await expect(db.transaction(tx => authorizeChatConversationForBoundRun(tx as unknown as typeof db,
binding, run.contextSnapshot, lockMode))).resolves.toEqual({ conversationId, endpointId });
}
capture.flush();
expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(12_000);
expect(Number(events.at(-1)?.completedQueries)).toBeGreaterThan(12);
expect(events.filter(event => event.queryErrors !== undefined).every(event => event.queryErrors === 0)).toBe(true);
expect((await db.select({ setup: chatEndpoints.setup }).from(chatEndpoints)
.where(eq(chatEndpoints.id, endpointId)))[0].setup).toEqual({ ...endpoint.setup, unusedContent: bulk });
expect((await db.select({ normalizedEvent: chatDeliveries.normalizedEvent }).from(chatDeliveries)
.where(eq(chatDeliveries.id, deliveries[0].id)))[0].normalizedEvent)
.toEqual({ ...deliveries[0].normalizedEvent, unusedContent: bulk });
} finally {
capture?.flush();
await db.update(chatEndpoints).set({ communicationInstructions: endpoint.communicationInstructions, setup: endpoint.setup })
.where(eq(chatEndpoints.id, endpointId));
await db.update(chatConversations).set({ communicationGuidance: conversation.communicationGuidance })
.where(eq(chatConversations.id, conversationId));
await db.update(chatEndpointResources).set({ metadata: resource.metadata }).where(eq(chatEndpointResources.id, resourceId));
for (const delivery of deliveries) {
await db.update(chatDeliveries).set({ normalizedEvent: delivery.normalizedEvent }).where(eq(chatDeliveries.id, delivery.id));
}
}
});
it.each([
{ label: "matching numeric generation", generation: 7, fingerprint: "a".repeat(64), allowed: true },
{ label: "string generation", generation: "7", fingerprint: "a".repeat(64), allowed: false },
{ label: "wrong generation", generation: 8, fingerprint: "a".repeat(64), allowed: false },
{ label: "missing generation", generation: undefined, fingerprint: "a".repeat(64), allowed: false },
{ label: "invalid fingerprint", generation: 7, fingerprint: "not-a-fingerprint", allowed: false },
{ label: "numeric fingerprint", generation: 7, fingerprint: 7, allowed: false },
])("preserves setup-test authorization for $label", async ({ generation, fingerprint, allowed }) => {
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
const deliveries = await db.select().from(chatDeliveries).where(eq(chatDeliveries.endpointId, endpointId));
const [run] = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
try {
const setup = { step: "test" as const, testStartedAt: "2000-01-01T00:00:00.000Z", runtimeGeneration: 7 };
await db.update(chatEndpoints).set({ status: "verifying", setup }).where(eq(chatEndpoints.id, endpointId));
for (const delivery of deliveries) {
await db.update(chatDeliveries).set({
normalizedEvent: { runtimeContext: { generation, credentialFingerprint: fingerprint } },
receivedAt: new Date("2000-01-01T00:00:01.000Z"),
processedAt: new Date("2000-01-01T00:00:02.000Z"),
})
.where(eq(chatDeliveries.id, delivery.id));
}
const result = db.transaction(tx => authorizeChatConversationForBoundRun(tx as unknown as typeof db,
{ companyId, agentId, issueId, runId }, run.contextSnapshot));
if (allowed) await expect(result).resolves.toEqual({ conversationId, endpointId });
else await expect(result).rejects.toThrow("paperclip_runner_chat_attachment_binding_denied");
} finally {
await db.update(chatEndpoints).set({ status: endpoint.status, setup: endpoint.setup }).where(eq(chatEndpoints.id, endpointId));
for (const delivery of deliveries) {
await db.update(chatDeliveries).set({ normalizedEvent: delivery.normalizedEvent,
receivedAt: delivery.receivedAt, processedAt: delivery.processedAt,
}).where(eq(chatDeliveries.id, delivery.id));
}
}
});
it("authorizes an external-chat response wait only from current durable policy", async () => {
const binding = { companyId, agentId, issueId, runId };
const alternateRunId = randomUUID();
const [run] = await db
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId));
await db.insert(heartbeatRuns).values({
id: alternateRunId,
companyId,
agentId,
status: "succeeded",
runtimeMode: "native",
nativeIssueId: issueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: {},
});
try {
await expect(
resolveExternalChatResponseWaitAuthorization({ db, binding }),
).resolves.toBe("authorized");
await db
.update(issues)
.set({ assigneeAgentId: null })
.where(eq(issues.id, issueId));
await expect(
resolveExternalChatResponseWaitAuthorization({ db, binding }),
).resolves.toBe("revoked");
await db
.update(issues)
.set({ assigneeAgentId: agentId, executionRunId: alternateRunId })
.where(eq(issues.id, issueId));
await expect(
resolveExternalChatResponseWaitAuthorization({ db, binding }),
).resolves.toBe("revoked");
await db
.update(issues)
.set({ executionRunId: runId })
.where(eq(issues.id, issueId));
await db
.update(heartbeatRuns)
.set({ status: "succeeded", updatedAt: new Date() })
.where(eq(heartbeatRuns.id, runId));
await expect(
resolveExternalChatResponseWaitAuthorization({ db, binding }),
).resolves.toBe("revoked");
await db
.update(heartbeatRuns)
.set({ status: "running", updatedAt: new Date() })
.where(eq(heartbeatRuns.id, runId));
await db
.update(companyMemberships)
.set({ status: "suspended", updatedAt: new Date() })
.where(eq(companyMemberships.principalId, userId));
await expect(
resolveExternalChatResponseWaitAuthorization({ db, binding }),
).resolves.toBe("revoked");
await db
.update(companyMemberships)
.set({ status: "active", updatedAt: new Date() })
.where(eq(companyMemberships.principalId, userId));
const spoofedContext = {
...((run?.contextSnapshot as Record<string, unknown> | null) ?? {}),
};
delete spoofedContext.paperclipWake;
await db
.update(heartbeatRuns)
.set({
contextSnapshot: spoofedContext,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, runId));
await expect(
resolveExternalChatResponseWaitAuthorization({ db, binding }),
).resolves.toBe("not_applicable");
} finally {
await db
.update(issues)
.set({ assigneeAgentId: agentId, executionRunId: runId })
.where(eq(issues.id, issueId));
await db
.update(heartbeatRuns)
.set({
status: "running",
contextSnapshot: run?.contextSnapshot ?? {},
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, runId));
await db
.update(companyMemberships)
.set({ status: "active", updatedAt: new Date() })
.where(eq(companyMemberships.principalId, userId));
await db
.delete(heartbeatRuns)
.where(eq(heartbeatRuns.id, alternateRunId));
}
});
it("lists metadata only and clones exact bytes once into current-run provenance", async () => {
const runner = authority();
const listed = await runner.execute({
tool: "list_chat_attachments",
callId: "list",
arguments: { sourceCommentId, limit: 10 },
});
expect(listed).toEqual({
attachments: [
{
sourceCommentId,
attachmentId: sourceAttachmentId,
filename: "earlier.txt",
contentType: "text/plain",
byteSize: sourceBody.length,
sha256: createHash("sha256").update(sourceBody).digest("hex"),
createdAt: expect.any(String),
contentAccess: "metadata_only",
},
],
nextCursor: null,
complete: true,
});
expect(JSON.stringify(listed)).not.toContain("objectKey");
expect(JSON.stringify(listed)).not.toContain("/api/attachments/");
const call = {
tool: "reuse_chat_attachment",
callId: "reuse",
arguments: {
idempotencyKey: "reuse-earlier-v1",
sourceCommentId,
attachmentId: sourceAttachmentId,
title: "Earlier requested file",
},
};
const first = (await runner.execute(call)) as Record<string, unknown>;
const replay = await runner.execute({ ...call, callId: "reuse-replay" });
const duplicate = (await runner.execute({
...call,
callId: "reuse-new-key",
arguments: { ...call.arguments, idempotencyKey: "reuse-earlier-v2" },
})) as Record<string, unknown>;
expect(first).toMatchObject({
disposition: "applied",
source: { commentId: sourceCommentId, attachmentId: sourceAttachmentId },
prepared: {
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"),
},
});
expect(replay).toEqual(first);
expect(duplicate).toMatchObject({
disposition: "duplicate",
prepared: (first as { prepared: unknown }).prepared,
});
const preparedId = (first as { prepared: { attachmentId: string } })
.prepared.attachmentId;
const attachments = await db
.select({
id: issueAttachments.id,
originatingRunId: issueAttachments.originatingRunId,
objectKey: assets.objectKey,
sha256: assets.sha256,
})
.from(issueAttachments)
.innerJoin(assets, eq(assets.id, issueAttachments.assetId))
.where(eq(issueAttachments.issueId, issueId));
expect(attachments).toHaveLength(2);
expect(attachments.find((row) => row.id === preparedId)).toMatchObject({
originatingRunId: runId,
sha256: createHash("sha256").update(sourceBody).digest("hex"),
});
const prepared = attachments.find((row) => row.id === preparedId)!;
const object = await storage.getObject(companyId, prepared.objectKey);
const chunks: Buffer[] = [];
for await (const chunk of object.stream) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks)).toEqual(sourceBody);
const [workProduct] = await db
.select()
.from(issueWorkProducts)
.where(eq(issueWorkProducts.externalId, preparedId));
expect(workProduct).toMatchObject({
createdByRunId: runId,
metadata: expect.objectContaining({
contentPath: `/api/attachments/${preparedId}/content`,
reusedFromAttachmentId: sourceAttachmentId,
reusedFromCommentId: sourceCommentId,
}),
});
const [run] = await db
.select({ resultJson: heartbeatRuns.resultJson })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId));
const receipts = (
run.resultJson as {
semanticToolReceipts: Record<
string,
{
operationId: string;
input: unknown;
result: unknown;
}
>;
}
).semanticToolReceipts;
expect(receipts["reuse-earlier-v1"]).toMatchObject({
operationId: "reuse_chat_attachment",
input: call.arguments,
});
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 ?? {}),
nativeResult: {
schema: "paperclip.run_result.v1",
summary: "Prepared the earlier file again.",
},
},
"Prepared the earlier file again.",
);
await db
.update(heartbeatRuns)
.set({ resultJson: completedResult })
.where(eq(heartbeatRuns.id, runId));
const persistedCompletion = (
await db
.select({ resultJson: heartbeatRuns.resultJson })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
)[0]?.resultJson as {
semanticToolReceipts: typeof receipts;
nativeResult: { summary: string };
};
expect(persistedCompletion.semanticToolReceipts).toEqual(receipts);
expect(persistedCompletion.nativeResult.summary).toBe(
"Prepared the earlier file again.",
);
const reuseActivity = (
await db.select().from(activityLog).where(eq(activityLog.runId, runId))
).find(
(row) => row.details?.source === "paperclip_runner_chat_attachment_reuse",
);
expect(reuseActivity?.details).toMatchObject({
sourceAttachmentId,
sourceCommentId,
attachmentId: preparedId,
});
});
it("bounds a stalled storage write and removes the late object without creating rows", async () => {
const [source] = await db
.select({
sourceCommentId: issueAttachments.issueCommentId,
attachmentId: issueAttachments.id,
filename: assets.originalFilename,
contentType: assets.contentType,
byteSize: assets.byteSize,
sha256: assets.sha256,
objectKey: assets.objectKey,
createdAt: issueAttachments.createdAt,
})
.from(issueAttachments)
.innerJoin(assets, eq(assets.id, issueAttachments.assetId))
.where(eq(issueAttachments.id, sourceAttachmentId));
if (!source?.sourceCommentId || !source.filename) {
throw new Error("source fixture missing");
}
let releaseWrite!: () => void;
const writeGate = new Promise<void>((resolve) => {
releaseWrite = resolve;
});
let resolveDeleted!: (objectKey: string) => void;
const deleted = new Promise<string>((resolve) => {
resolveDeleted = resolve;
});
const delayedStorage: StorageService = {
...storage,
putFile: async (input) => {
const stored = await storage.putFile(input);
await writeGate;
return stored;
},
deleteObject: async (targetCompanyId, objectKey) => {
await storage.deleteObject(targetCompanyId, objectKey);
resolveDeleted(objectKey);
},
};
const before = await db
.select({ id: issueAttachments.id })
.from(issueAttachments)
.where(eq(issueAttachments.issueId, issueId));
await expect(
prepareReusedChatAttachment({
db,
binding: { companyId, issueId, runId, agentId },
source: source as ChatAttachmentReuseSource,
title: "Must time out",
storage: delayedStorage,
storageTimeoutMs: 20,
}),
).rejects.toThrow(
"paperclip_runner_chat_attachment_storage_write_timed_out",
);
releaseWrite();
const deletedObjectKey = await Promise.race([
deleted,
new Promise<never>((_resolve, reject) =>
setTimeout(() => reject(new Error("late object was not removed")), 500),
),
]);
expect((await storage.headObject(companyId, deletedObjectKey)).exists).toBe(
false,
);
expect(
await db
.select({ id: issueAttachments.id })
.from(issueAttachments)
.where(eq(issueAttachments.issueId, issueId)),
).toEqual(before);
});
it("pages a stable deduplicated attachment history with equal timestamps", async () => {
const equalCreatedAt = new Date("2026-09-07T18:00:00.000Z");
const newAttachmentIds: string[] = [];
for (let index = 0; index < 3; index += 1) {
const comment = await issueService(db).addComment(
issueId,
`Historical upload ${index}`,
{ userId },
);
const body = Buffer.from(`historical-${index}\n`, "utf8");
const stored = await storage.putFile({
companyId,
namespace: `issues/${issueId}`,
originalFilename: `historical-${index}.txt`,
contentType: "text/plain",
body,
});
const attachment = await issueService(db).createAttachment({
issueId,
issueCommentId: comment.id,
provider: stored.provider,
objectKey: stored.objectKey,
contentType: stored.contentType,
byteSize: stored.byteSize,
sha256: stored.sha256,
originalFilename: stored.originalFilename,
createdByUserId: userId,
});
newAttachmentIds.push(attachment.id);
await db
.update(issueAttachments)
.set({ createdAt: equalCreatedAt })
.where(eq(issueAttachments.id, attachment.id));
const deliveryId = randomUUID();
await db.insert(chatDeliveries).values({
id: deliveryId,
companyId,
endpointId,
conversationId,
principalId,
providerEventId: `historical-event-${index}`,
deduplicationKey: `historical-event-${index}`,
eventKind: "message",
normalizedEvent: {},
state: "processed",
attempts: 1,
processedAt: new Date(),
});
await db.insert(chatMessageLinks).values({
companyId,
endpointId,
conversationId,
deliveryId,
commentId: comment.id,
providerMessageId: `historical-message-${index}`,
direction: "inbound",
});
}
const [publicationCommentA, publicationCommentB] = await Promise.all([
issueService(db).addComment(issueId, "Published file A", { userId }),
issueService(db).addComment(issueId, "Published file B", { userId }),
]);
for (const [index, comment] of [
publicationCommentA,
publicationCommentB,
].entries()) {
await db.insert(chatPublications).values({
companyId,
endpointId,
conversationId,
issueId,
commentId: comment.id,
idempotencyKey: `published-duplicate-${index}`,
payload: {
text: "Shared the earlier file.",
attachmentIds: [sourceAttachmentId],
},
state: "published",
providerMessageId: `published-message-${index}`,
publishedAt: new Date(),
});
}
const seen: string[] = [];
let cursor: string | null = null;
for (let pageNumber = 0; pageNumber < 4; pageNumber += 1) {
const page = (await authority().execute({
tool: "list_chat_attachments",
callId: `page-${pageNumber}`,
arguments: { limit: 2, cursor },
})) as {
attachments: Array<{ attachmentId: string }>;
nextCursor: string | null;
complete: boolean;
};
seen.push(
...page.attachments.map((attachment) => attachment.attachmentId),
);
cursor = page.nextCursor;
if (page.complete) break;
}
expect(new Set(seen)).toEqual(
new Set([sourceAttachmentId, ...newAttachmentIds]),
);
expect(seen).toHaveLength(4);
const invalidCursor = Buffer.from(
JSON.stringify({
schema: "paperclip.chat-attachment-list-cursor.v1",
conversationId,
sourceCommentId: null,
createdAt: equalCreatedAt.toISOString(),
attachmentId: "not-a-uuid",
sourceCommentIdTieBreak: "also-not-a-uuid",
}),
"utf8",
).toString("base64url");
await expect(
authority().execute({
tool: "list_chat_attachments",
callId: "malformed-cursor",
arguments: { cursor: invalidCursor },
}),
).rejects.toThrow("paperclip_runner_chat_attachment_cursor_invalid");
});
it("keeps an older confirmed lineage when a newer publication was deleted", async () => {
const stored = await storage.putFile({
companyId,
namespace: `issues/${issueId}`,
originalFilename: "published-twice.txt",
contentType: "text/plain",
body: Buffer.from("published twice\n", "utf8"),
});
const parent = await issueService(db).addComment(
issueId,
"Internal preparation parent",
{ userId },
);
const attachment = await issueService(db).createAttachment({
issueId,
issueCommentId: parent.id,
provider: stored.provider,
objectKey: stored.objectKey,
contentType: stored.contentType,
byteSize: stored.byteSize,
sha256: stored.sha256,
originalFilename: stored.originalFilename,
createdByUserId: userId,
});
const olderCommentId = "20000000-0000-4000-8000-000000000111";
const newerCommentId = "20000000-0000-4000-8000-000000000222";
await db.insert(issueComments).values([
{
id: olderCommentId,
companyId,
issueId,
authorType: "user",
authorUserId: userId,
body: "First confirmed publication",
},
{
id: newerCommentId,
companyId,
issueId,
authorType: "user",
authorUserId: userId,
body: "Later deleted publication",
},
]);
await db.insert(chatPublications).values([
{
companyId,
endpointId,
conversationId,
issueId,
commentId: olderCommentId,
idempotencyKey: "confirmed-published-older",
payload: { text: "First send", attachmentIds: [attachment.id] },
state: "published",
providerMessageId: "confirmed-older",
publishedAt: new Date("2026-09-07T18:01:00.000Z"),
},
{
companyId,
endpointId,
conversationId,
issueId,
commentId: newerCommentId,
idempotencyKey: "confirmed-published-newer",
payload: { text: "Second send", attachmentIds: [attachment.id] },
state: "published",
providerMessageId: "confirmed-newer",
publishedAt: new Date("2026-09-07T18:02:00.000Z"),
},
]);
await db.insert(chatDeliveries).values({
companyId,
endpointId,
conversationId,
principalId,
providerEventId: "delete-newer-publication",
deduplicationKey: "delete-newer-publication",
eventKind: "message_deleted",
normalizedEvent: {
message: { providerMessageId: "confirmed-newer" },
},
state: "processed",
attempts: 1,
processedAt: new Date(),
});
const page = (await authority().execute({
tool: "list_chat_attachments",
callId: "multi-lineage-list",
arguments: { limit: 50 },
})) as {
attachments: Array<{ attachmentId: string; sourceCommentId: string }>;
};
expect(
page.attachments.find(
(candidate) => candidate.attachmentId === attachment.id,
),
).toMatchObject({ sourceCommentId: olderCommentId });
await expect(
authority().execute({
tool: "reuse_chat_attachment",
callId: "deleted-newer-lineage",
arguments: {
idempotencyKey: "deleted-newer-lineage",
sourceCommentId: newerCommentId,
attachmentId: attachment.id,
title: "Deleted newer copy",
},
}),
).rejects.toThrow("paperclip_runner_chat_attachment_source_denied");
await expect(
authority().execute({
tool: "reuse_chat_attachment",
callId: "confirmed-older-lineage",
arguments: {
idempotencyKey: "confirmed-older-lineage",
sourceCommentId: olderCommentId,
attachmentId: attachment.id,
title: "Still confirmed older copy",
},
}),
).resolves.toMatchObject({ disposition: "applied" });
const unconfirmedStored = await storage.putFile({
companyId,
namespace: `issues/${issueId}`,
originalFilename: "unconfirmed.txt",
contentType: "text/plain",
body: Buffer.from("never confirmed\n", "utf8"),
});
const unconfirmedParent = await issueService(db).addComment(
issueId,
"Unconfirmed preparation parent",
{ userId },
);
const unconfirmedAttachment = await issueService(db).createAttachment({
issueId,
issueCommentId: unconfirmedParent.id,
provider: unconfirmedStored.provider,
objectKey: unconfirmedStored.objectKey,
contentType: unconfirmedStored.contentType,
byteSize: unconfirmedStored.byteSize,
sha256: unconfirmedStored.sha256,
originalFilename: unconfirmedStored.originalFilename,
createdByUserId: userId,
});
const unconfirmedComment = await issueService(db).addComment(
issueId,
"Publication row without provider confirmation",
{ userId },
);
await db.insert(chatPublications).values({
companyId,
endpointId,
conversationId,
issueId,
commentId: unconfirmedComment.id,
idempotencyKey: "unconfirmed-publication",
payload: {
text: "Not actually sent",
attachmentIds: [unconfirmedAttachment.id],
},
state: "published",
providerMessageId: null,
publishedAt: null,
});
expect(
await authority().execute({
tool: "list_chat_attachments",
callId: "unconfirmed-list",
arguments: { sourceCommentId: unconfirmedComment.id },
}),
).toEqual({ attachments: [], nextCursor: null, complete: true });
await expect(
authority().execute({
tool: "reuse_chat_attachment",
callId: "unconfirmed-reuse",
arguments: {
idempotencyKey: "unconfirmed-reuse",
sourceCommentId: unconfirmedComment.id,
attachmentId: unconfirmedAttachment.id,
title: "Must remain internal",
},
}),
).rejects.toThrow("paperclip_runner_chat_attachment_source_denied");
});
it("rejects forged source pairs, stale reach, and deleted historical sources", async () => {
const runner = authority();
const stored = await storage.putFile({
companyId,
namespace: `issues/${issueId}`,
originalFilename: "internal-only.txt",
contentType: "text/plain",
body: Buffer.from("internal only"),
});
const internalComment = await issueService(db).addComment(
issueId,
"Internal-only file",
{ userId: "internal-user" },
);
const internal = await issueService(db).createAttachment({
issueId,
issueCommentId: internalComment.id,
provider: stored.provider,
objectKey: stored.objectKey,
contentType: stored.contentType,
byteSize: stored.byteSize,
sha256: stored.sha256,
originalFilename: stored.originalFilename,
createdByUserId: "internal-user",
});
await expect(
runner.execute({
tool: "reuse_chat_attachment",
callId: "forged-pair",
arguments: {
idempotencyKey: "forged-pair",
sourceCommentId,
attachmentId: internal.id,
title: "Must not escape",
},
}),
).rejects.toThrow("paperclip_runner_chat_attachment_source_denied");
await db
.update(issues)
.set({ assigneeAgentId: null })
.where(eq(issues.id, issueId));
await expect(
runner.execute({
tool: "list_chat_attachments",
callId: "reassigned-list",
arguments: {},
}),
).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
await db
.update(issues)
.set({ assigneeAgentId: agentId })
.where(eq(issues.id, issueId));
await db
.update(chatEndpoints)
.set({ status: "paused" })
.where(eq(chatEndpoints.id, endpointId));
await expect(
runner.execute({
tool: "list_chat_attachments",
callId: "paused-endpoint-list",
arguments: {},
}),
).rejects.toThrow("paperclip_runner_chat_attachment_binding_denied");
await db
.update(chatEndpoints)
.set({ status: "active" })
.where(eq(chatEndpoints.id, endpointId));
await db
.update(companyMemberships)
.set({ status: "suspended" })
.where(eq(companyMemberships.principalId, userId));
await expect(
runner.execute({
tool: "reuse_chat_attachment",
callId: "revoked-principal-replay",
arguments: {
idempotencyKey: "reuse-earlier-v1",
sourceCommentId,
attachmentId: sourceAttachmentId,
title: "Earlier requested file",
},
}),
).rejects.toThrow("paperclip_runner_chat_attachment_principal_denied");
await db
.update(companyMemberships)
.set({ status: "active" })
.where(eq(companyMemberships.principalId, userId));
await db
.update(issues)
.set({ workMode: "ask" })
.where(eq(issues.id, issueId));
await expect(
runner.execute({
tool: "reuse_chat_attachment",
callId: "ask-mode-reuse",
arguments: {
idempotencyKey: "ask-mode-reuse",
sourceCommentId,
attachmentId: sourceAttachmentId,
title: "Earlier requested file",
},
}),
).rejects.toThrow("paperclip_runner_tool_mode_denied");
await db
.update(issues)
.set({ workMode: "standard" })
.where(eq(issues.id, issueId));
await db
.update(chatEndpointResources)
.set({ enabled: false })
.where(eq(chatEndpointResources.id, resourceId));
await expect(
runner.execute({
tool: "list_chat_attachments",
callId: "disabled-reach",
arguments: {},
}),
).rejects.toThrow("paperclip_runner_chat_attachment_destination_denied");
await db
.update(chatEndpointResources)
.set({ enabled: true })
.where(eq(chatEndpointResources.id, resourceId));
await db
.update(issueComments)
.set({ deletedAt: new Date() })
.where(eq(issueComments.id, sourceCommentId));
await expect(
runner.execute({
tool: "reuse_chat_attachment",
callId: "deleted-source-replay",
arguments: {
idempotencyKey: "reuse-earlier-v1",
sourceCommentId,
attachmentId: sourceAttachmentId,
title: "Earlier requested file",
},
}),
).rejects.toThrow("paperclip_runner_chat_attachment_source_denied");
expect(
await runner.execute({
tool: "list_chat_attachments",
callId: "deleted-source-list",
arguments: { sourceCommentId },
}),
).toEqual({ attachments: [], nextCursor: null, complete: true });
await db
.update(issueComments)
.set({ deletedAt: null })
.where(eq(issueComments.id, sourceCommentId));
await db.insert(chatDeliveries).values({
companyId,
endpointId,
conversationId,
principalId,
providerEventId: "source-edit-event",
deduplicationKey: "source-edit-event",
eventKind: "message_updated",
normalizedEvent: {
message: { targetProviderEventId: "source-event" },
},
state: "processed",
attempts: 1,
processedAt: new Date(),
});
await expect(
runner.execute({
tool: "reuse_chat_attachment",
callId: "edited-source-replay",
arguments: {
idempotencyKey: "reuse-earlier-v1",
sourceCommentId,
attachmentId: sourceAttachmentId,
title: "Earlier requested file",
},
}),
).rejects.toThrow("paperclip_runner_chat_attachment_source_denied");
});
});