mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
fix(attention): scope settled confirmations to the same recipient and governed actions
Review follow-up. The summary and task-index payload now keeps toolAction and secretProposal, so pending governed approvals are never treated as ordinary confirmations there. A settled confirmation only hides older asks addressed to the same recipient. Secret proposals are excluded like tool actions. Tests now use two agents (same-agent re-asks expire at creation) and cover reject, a re-ask after a settled card, another recipient, secret proposals, and the summary/task index. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ebe8e0c0ae
commit
9245bd7b17
3 files changed
+109
-15
No files matched your search
@@ -1542,13 +1542,16 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["approved from chat", "accept"],
|
||||
["expired", "expire"],
|
||||
] as const)("does not resurface an older confirmation once the newest one is %s", async (_label, outcome) => {
|
||||
const { companyId, workerId } = await seedCompany(outcome === "accept" ? "ATR" : "ATX");
|
||||
["approved from chat", "accept", "ATR"],
|
||||
["rejected from chat", "reject", "ATJ"],
|
||||
["expired", "expire", "ATX"],
|
||||
] as const)("does not resurface an older confirmation once the newest one is %s", async (_label, outcome, prefix) => {
|
||||
// Same-agent re-asks are expired at creation; a second agent (for example
|
||||
// the chat agent) asking on the same task leaves the older card pending.
|
||||
const { companyId, workerId, reviewerId } = await seedCompany(prefix);
|
||||
const issueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: outcome === "accept" ? "ATR-1" : "ATX-1",
|
||||
identifier: `${prefix}-1`,
|
||||
title: "Re-asked sign-off",
|
||||
status: "in_review",
|
||||
assigneeAgentId: workerId,
|
||||
@@ -1576,7 +1579,7 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "none",
|
||||
createdByAgentId: workerId,
|
||||
createdByAgentId: reviewerId,
|
||||
title: "Approve the plan (again)",
|
||||
payload: { version: 1, prompt: "Approve the plan?" },
|
||||
createdAt: new Date("2026-11-05T12:05:00.000Z"),
|
||||
@@ -1597,6 +1600,13 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
{},
|
||||
{ userId: "board-user" },
|
||||
);
|
||||
} else if (outcome === "reject") {
|
||||
await issueThreadInteractionService(db).rejectInteraction(
|
||||
{ id: issueId, companyId, status: "in_review" },
|
||||
newerConfirmationId,
|
||||
{ reason: "Not this plan" },
|
||||
{ userId: "board-user" },
|
||||
);
|
||||
} else {
|
||||
await db.update(issueThreadInteractions).set({
|
||||
status: "expired",
|
||||
@@ -1613,17 +1623,85 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
const { companyId, workerId } = await seedCompany("ATT");
|
||||
const issueId = await insertIssue({ companyId, identifier: "ATT-1", title: "Two tool calls", status: "in_progress", assigneeAgentId: workerId });
|
||||
const olderId = randomUUID();
|
||||
const base = { companyId, issueId, kind: "request_confirmation" as const, continuationPolicy: "none" as const, createdByAgentId: workerId, title: "Approve tool action" };
|
||||
const base = { companyId, issueId, kind: "request_confirmation", continuationPolicy: "none", createdByAgentId: workerId, title: "Approve tool action" };
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{ ...base, id: olderId, status: "pending", payload: { version: 1, prompt: "Run A?", toolAction: { actionRequestId: randomUUID() } },
|
||||
createdAt: new Date("2026-11-05T12:00:00.000Z"), updatedAt: new Date("2026-11-05T12:00:00.000Z") },
|
||||
{ ...base, id: randomUUID(), status: "accepted", payload: { version: 1, prompt: "Run B?", toolAction: { actionRequestId: randomUUID() } },
|
||||
createdAt: new Date("2026-11-05T12:05:00.000Z"), updatedAt: new Date("2026-11-05T12:06:00.000Z"), resolvedAt: new Date("2026-11-05T12:06:00.000Z") },
|
||||
]);
|
||||
] as typeof issueThreadInteractions.$inferInsert[]);
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
expect(feed.items.filter((item) => item.sourceKind === "issue_thread_interaction").map((item) => item.subject.id)).toEqual([olderId]);
|
||||
});
|
||||
|
||||
it("shows a genuine re-ask created after the newest confirmation was settled", async () => {
|
||||
const { companyId, workerId, reviewerId } = await seedCompany("ATN");
|
||||
const issueId = await insertIssue({ companyId, identifier: "ATN-1", title: "Follow-up sign-off", status: "in_review", assigneeAgentId: workerId });
|
||||
const reAskId = randomUUID();
|
||||
const base = { companyId, issueId, kind: "request_confirmation", continuationPolicy: "none", payload: { version: 1, prompt: "Approve?" } };
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{ ...base, id: randomUUID(), status: "accepted", createdByAgentId: reviewerId, title: "First sign-off",
|
||||
createdAt: new Date("2026-11-05T12:00:00.000Z"), updatedAt: new Date("2026-11-05T12:01:00.000Z"), resolvedAt: new Date("2026-11-05T12:01:00.000Z") },
|
||||
{ ...base, id: reAskId, status: "pending", createdByAgentId: workerId, title: "Second sign-off",
|
||||
createdAt: new Date("2026-11-05T12:05:00.000Z"), updatedAt: new Date("2026-11-05T12:05:00.000Z") },
|
||||
] as typeof issueThreadInteractions.$inferInsert[]);
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
expect(feed.items.filter((item) => item.sourceKind === "issue_thread_interaction").map((item) => item.subject.id)).toEqual([reAskId]);
|
||||
});
|
||||
|
||||
it("does not let a settled secret proposal hide an older ordinary confirmation", async () => {
|
||||
const { companyId, workerId, reviewerId } = await seedCompany("ATS");
|
||||
const issueId = await insertIssue({ companyId, identifier: "ATS-1", title: "Secret then sign-off", status: "in_review", assigneeAgentId: workerId });
|
||||
const ordinaryId = randomUUID();
|
||||
const base = { companyId, issueId, kind: "request_confirmation", continuationPolicy: "none" };
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{ ...base, id: ordinaryId, status: "pending", createdByAgentId: workerId, title: "Approve the plan", payload: { version: 1, prompt: "Approve the plan?" },
|
||||
createdAt: new Date("2026-11-05T12:00:00.000Z"), updatedAt: new Date("2026-11-05T12:00:00.000Z") },
|
||||
{ ...base, id: randomUUID(), status: "accepted", createdByAgentId: reviewerId, title: "Store API key",
|
||||
payload: { version: 1, prompt: "Store this key?", secretProposal: { proposalId: randomUUID() } },
|
||||
createdAt: new Date("2026-11-05T12:05:00.000Z"), updatedAt: new Date("2026-11-05T12:06:00.000Z"), resolvedAt: new Date("2026-11-05T12:06:00.000Z") },
|
||||
] as typeof issueThreadInteractions.$inferInsert[]);
|
||||
const service = attentionService(db);
|
||||
const feed = await service.list(companyId, { userId: "board-user" });
|
||||
expect(feed.items.filter((item) => item.sourceKind === "issue_thread_interaction").map((item) => item.subject.id)).toEqual([ordinaryId]);
|
||||
expect((await service.taskIndex(companyId, { userId: "board-user" })).taskIds).toContain(issueId);
|
||||
});
|
||||
|
||||
it("keeps a pending tool-action approval in the summary and task index after a newer ordinary confirmation is settled", async () => {
|
||||
const { companyId, workerId } = await seedCompany("ATU");
|
||||
const issueId = await insertIssue({ companyId, identifier: "ATU-1", title: "Tool call then sign-off", status: "in_progress", assigneeAgentId: workerId });
|
||||
const toolApprovalId = randomUUID();
|
||||
const base = { companyId, issueId, kind: "request_confirmation", continuationPolicy: "none", createdByAgentId: workerId };
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{ ...base, id: toolApprovalId, status: "pending", title: "Approve tool action",
|
||||
payload: { version: 1, prompt: "Run A?", toolAction: { actionRequestId: randomUUID() } },
|
||||
createdAt: new Date("2026-11-05T12:00:00.000Z"), updatedAt: new Date("2026-11-05T12:00:00.000Z") },
|
||||
{ ...base, id: randomUUID(), status: "accepted", title: "Approve the plan", payload: { version: 1, prompt: "Approve the plan?" },
|
||||
createdAt: new Date("2026-11-05T12:05:00.000Z"), updatedAt: new Date("2026-11-05T12:06:00.000Z"), resolvedAt: new Date("2026-11-05T12:06:00.000Z") },
|
||||
] as typeof issueThreadInteractions.$inferInsert[]);
|
||||
const service = attentionService(db);
|
||||
const feed = await service.list(companyId, { userId: "board-user" });
|
||||
expect(feed.items.filter((item) => item.sourceKind === "issue_thread_interaction").map((item) => item.subject.id)).toEqual([toolApprovalId]);
|
||||
const [summary, index] = await Promise.all([service.summary(companyId, { userId: "board-user" }), service.taskIndex(companyId, { userId: "board-user" })]);
|
||||
expect(index.taskIds).toContain(issueId);
|
||||
expect(summary.totalCount).toBe(feed.totalCount);
|
||||
});
|
||||
|
||||
it("does not let another recipient's settled confirmation hide this recipient's pending one", async () => {
|
||||
const { companyId, workerId } = await seedCompany("ATV");
|
||||
const issueId = await insertIssue({ companyId, identifier: "ATV-1", title: "Two reviewers", status: "in_review", assigneeAgentId: workerId });
|
||||
const mineId = randomUUID();
|
||||
const base = { companyId, issueId, kind: "request_confirmation", continuationPolicy: "none", createdByAgentId: workerId, payload: { version: 1, prompt: "Approve?" } };
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{ ...base, id: mineId, status: "pending", title: "Board user sign-off", addresseeUserId: "board-user",
|
||||
createdAt: new Date("2026-11-05T12:00:00.000Z"), updatedAt: new Date("2026-11-05T12:00:00.000Z") },
|
||||
{ ...base, id: randomUUID(), status: "accepted", title: "Other user sign-off", addresseeUserId: "other-user",
|
||||
createdAt: new Date("2026-11-05T12:05:00.000Z"), updatedAt: new Date("2026-11-05T12:06:00.000Z"), resolvedAt: new Date("2026-11-05T12:06:00.000Z") },
|
||||
] as typeof issueThreadInteractions.$inferInsert[]);
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
expect(feed.items.filter((item) => item.sourceKind === "issue_thread_interaction").map((item) => item.subject.id)).toEqual([mineId]);
|
||||
});
|
||||
|
||||
it("retains every independently bound quality approval alongside the newest ordinary confirmation", async () => {
|
||||
const { companyId, workerId } = await seedCompany("ATQ");
|
||||
const issueId = await insertIssue({ companyId, identifier: "ATQ-1", title: "Review copy and visual", status: "in_review", assigneeAgentId: workerId });
|
||||
|
||||
@@ -11,17 +11,28 @@ export function recoveryActionAttentionCondition() {
|
||||
export const ATTENTION_BUDGET_WARNING_PERCENT = 85;
|
||||
export const ATTENTION_AGENT_ERROR_STATUS = "error";
|
||||
|
||||
function confirmationAudienceKey(row: { issueId: string; addresseeUserId?: string | null; addresseeAgentId?: string | null }) {
|
||||
return `${row.issueId}:${row.addresseeUserId ?? ""}:${row.addresseeAgentId ?? ""}`;
|
||||
}
|
||||
|
||||
function payloadFlag(payload: unknown, key: string) {
|
||||
return Boolean(payload && typeof payload === "object" && key in payload && (payload as Record<string, unknown>)[key]);
|
||||
}
|
||||
|
||||
/** Tool actions and secret proposals gate one specific action; creation never supersedes siblings for them either. */
|
||||
function governedAction(payload: unknown) {
|
||||
return payloadFlag(payload, "toolAction") || payloadFlag(payload, "secretProposal");
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending confirmation is superseded once the same issue has a newer
|
||||
* ordinary confirmation — pending, or already answered/expired. Without the
|
||||
* settled case, answering the newest card (from chat or anywhere else)
|
||||
* resurfaces the older ask it replaced. Quality approvals stay
|
||||
* independent; tool-action approvals are gates for distinct calls, so a settled
|
||||
* one never hides another.
|
||||
* independent; tool-action and secret-proposal approvals gate one specific
|
||||
* action, so they neither hide nor are hidden by a settled confirmation.
|
||||
* Only an ask to the same recipient settles it, so one person's answer never
|
||||
* hides a request addressed to someone else.
|
||||
*/
|
||||
export function collapsePendingConfirmationsToNewest<T extends {
|
||||
id: string;
|
||||
@@ -29,6 +40,8 @@ export function collapsePendingConfirmationsToNewest<T extends {
|
||||
kind: string;
|
||||
createdAt: Date;
|
||||
payload: unknown;
|
||||
addresseeUserId?: string | null;
|
||||
addresseeAgentId?: string | null;
|
||||
}>(rows: T[], newestSettledConfirmationAt: ReadonlyMap<string, Date> = new Map()) {
|
||||
const independent = (row: T) => payloadFlag(row.payload, "qualityReview");
|
||||
const newestByGroup = new Map<string, T>();
|
||||
@@ -48,7 +61,7 @@ export function collapsePendingConfirmationsToNewest<T extends {
|
||||
return rows.filter((row) => {
|
||||
if (row.kind !== "request_confirmation" || independent(row)) return true;
|
||||
if (newestByGroup.get(`${row.issueId}:${row.kind}`)?.id !== row.id) return false;
|
||||
const settledAt = payloadFlag(row.payload, "toolAction") ? undefined : newestSettledConfirmationAt.get(row.issueId);
|
||||
const settledAt = governedAction(row.payload) ? undefined : newestSettledConfirmationAt.get(confirmationAudienceKey(row));
|
||||
return !settledAt || settledAt.getTime() <= row.createdAt.getTime();
|
||||
});
|
||||
}
|
||||
@@ -56,12 +69,14 @@ export function collapsePendingConfirmationsToNewest<T extends {
|
||||
// Cancelled/failed replacements do not settle the ask, so an older card returns.
|
||||
const SETTLED_CONFIRMATION_STATUSES = ["accepted", "rejected", "answered", "expired"] as const;
|
||||
|
||||
/** Newest answered/expired ordinary confirmation per issue (see the collapse above). */
|
||||
/** Newest answered/expired ordinary confirmation per issue and recipient (see the collapse above). */
|
||||
export async function loadNewestSettledConfirmationAt(db: Db, companyId: string, issueIds: string[]) {
|
||||
const ids = [...new Set(issueIds)];
|
||||
if (ids.length === 0) return new Map<string, Date>();
|
||||
const rows = await db.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
||||
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
||||
createdAt: sql<string>`max(${issueThreadInteractions.createdAt})::text`,
|
||||
}).from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, companyId),
|
||||
@@ -70,8 +85,9 @@ export async function loadNewestSettledConfirmationAt(db: Db, companyId: string,
|
||||
inArray(issueThreadInteractions.status, [...SETTLED_CONFIRMATION_STATUSES]),
|
||||
sql`jsonb_typeof(${issueThreadInteractions.payload}->'qualityReview') is distinct from 'object'`,
|
||||
sql`jsonb_typeof(${issueThreadInteractions.payload}->'toolAction') is distinct from 'object'`,
|
||||
)).groupBy(issueThreadInteractions.issueId);
|
||||
return new Map(rows.map((row) => [row.issueId, new Date(row.createdAt)]));
|
||||
sql`jsonb_typeof(${issueThreadInteractions.payload}->'secretProposal') is distinct from 'object'`,
|
||||
)).groupBy(issueThreadInteractions.issueId, issueThreadInteractions.addresseeUserId, issueThreadInteractions.addresseeAgentId);
|
||||
return new Map(rows.map((row) => [confirmationAudienceKey(row), new Date(row.createdAt)]));
|
||||
}
|
||||
|
||||
export function readRunIssueId(contextSnapshot: Record<string, unknown> | null) {
|
||||
|
||||
@@ -1179,7 +1179,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
status: issueThreadInteractions.status,
|
||||
title: issueThreadInteractions.title,
|
||||
summary: issueThreadInteractions.summary,
|
||||
payload: summaryOnly ? sql<Record<string, unknown>>`jsonb_build_object('target', ${issueThreadInteractions.payload}->'target', 'qualityReview', ${issueThreadInteractions.payload}->'qualityReview')` : issueThreadInteractions.payload,
|
||||
payload: summaryOnly ? sql<Record<string, unknown>>`jsonb_build_object('target', ${issueThreadInteractions.payload}->'target', 'qualityReview', ${issueThreadInteractions.payload}->'qualityReview', 'toolAction', ${issueThreadInteractions.payload}->'toolAction', 'secretProposal', ${issueThreadInteractions.payload}->'secretProposal')` : issueThreadInteractions.payload,
|
||||
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
||||
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
||||
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
||||
|
||||
Reference in new issue
Block a user