mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +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([
|
it.each([
|
||||||
["approved from chat", "accept"],
|
["approved from chat", "accept", "ATR"],
|
||||||
["expired", "expire"],
|
["rejected from chat", "reject", "ATJ"],
|
||||||
] as const)("does not resurface an older confirmation once the newest one is %s", async (_label, outcome) => {
|
["expired", "expire", "ATX"],
|
||||||
const { companyId, workerId } = await seedCompany(outcome === "accept" ? "ATR" : "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({
|
const issueId = await insertIssue({
|
||||||
companyId,
|
companyId,
|
||||||
identifier: outcome === "accept" ? "ATR-1" : "ATX-1",
|
identifier: `${prefix}-1`,
|
||||||
title: "Re-asked sign-off",
|
title: "Re-asked sign-off",
|
||||||
status: "in_review",
|
status: "in_review",
|
||||||
assigneeAgentId: workerId,
|
assigneeAgentId: workerId,
|
||||||
@@ -1576,7 +1579,7 @@ describeEmbeddedPostgres("attention service", () => {
|
|||||||
kind: "request_confirmation",
|
kind: "request_confirmation",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
continuationPolicy: "none",
|
continuationPolicy: "none",
|
||||||
createdByAgentId: workerId,
|
createdByAgentId: reviewerId,
|
||||||
title: "Approve the plan (again)",
|
title: "Approve the plan (again)",
|
||||||
payload: { version: 1, prompt: "Approve the plan?" },
|
payload: { version: 1, prompt: "Approve the plan?" },
|
||||||
createdAt: new Date("2026-11-05T12:05:00.000Z"),
|
createdAt: new Date("2026-11-05T12:05:00.000Z"),
|
||||||
@@ -1597,6 +1600,13 @@ describeEmbeddedPostgres("attention service", () => {
|
|||||||
{},
|
{},
|
||||||
{ userId: "board-user" },
|
{ 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 {
|
} else {
|
||||||
await db.update(issueThreadInteractions).set({
|
await db.update(issueThreadInteractions).set({
|
||||||
status: "expired",
|
status: "expired",
|
||||||
@@ -1613,17 +1623,85 @@ describeEmbeddedPostgres("attention service", () => {
|
|||||||
const { companyId, workerId } = await seedCompany("ATT");
|
const { companyId, workerId } = await seedCompany("ATT");
|
||||||
const issueId = await insertIssue({ companyId, identifier: "ATT-1", title: "Two tool calls", status: "in_progress", assigneeAgentId: workerId });
|
const issueId = await insertIssue({ companyId, identifier: "ATT-1", title: "Two tool calls", status: "in_progress", assigneeAgentId: workerId });
|
||||||
const olderId = randomUUID();
|
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([
|
await db.insert(issueThreadInteractions).values([
|
||||||
{ ...base, id: olderId, status: "pending", payload: { version: 1, prompt: "Run A?", toolAction: { actionRequestId: randomUUID() } },
|
{ ...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") },
|
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() } },
|
{ ...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") },
|
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" });
|
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]);
|
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 () => {
|
it("retains every independently bound quality approval alongside the newest ordinary confirmation", async () => {
|
||||||
const { companyId, workerId } = await seedCompany("ATQ");
|
const { companyId, workerId } = await seedCompany("ATQ");
|
||||||
const issueId = await insertIssue({ companyId, identifier: "ATQ-1", title: "Review copy and visual", status: "in_review", assigneeAgentId: workerId });
|
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_BUDGET_WARNING_PERCENT = 85;
|
||||||
export const ATTENTION_AGENT_ERROR_STATUS = "error";
|
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) {
|
function payloadFlag(payload: unknown, key: string) {
|
||||||
return Boolean(payload && typeof payload === "object" && key in payload && (payload as Record<string, unknown>)[key]);
|
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
|
* A pending confirmation is superseded once the same issue has a newer
|
||||||
* ordinary confirmation — pending, or already answered/expired. Without the
|
* ordinary confirmation — pending, or already answered/expired. Without the
|
||||||
* settled case, answering the newest card (from chat or anywhere else)
|
* settled case, answering the newest card (from chat or anywhere else)
|
||||||
* resurfaces the older ask it replaced. Quality approvals stay
|
* resurfaces the older ask it replaced. Quality approvals stay
|
||||||
* independent; tool-action approvals are gates for distinct calls, so a settled
|
* independent; tool-action and secret-proposal approvals gate one specific
|
||||||
* one never hides another.
|
* 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 {
|
export function collapsePendingConfirmationsToNewest<T extends {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,6 +40,8 @@ export function collapsePendingConfirmationsToNewest<T extends {
|
|||||||
kind: string;
|
kind: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
|
addresseeUserId?: string | null;
|
||||||
|
addresseeAgentId?: string | null;
|
||||||
}>(rows: T[], newestSettledConfirmationAt: ReadonlyMap<string, Date> = new Map()) {
|
}>(rows: T[], newestSettledConfirmationAt: ReadonlyMap<string, Date> = new Map()) {
|
||||||
const independent = (row: T) => payloadFlag(row.payload, "qualityReview");
|
const independent = (row: T) => payloadFlag(row.payload, "qualityReview");
|
||||||
const newestByGroup = new Map<string, T>();
|
const newestByGroup = new Map<string, T>();
|
||||||
@@ -48,7 +61,7 @@ export function collapsePendingConfirmationsToNewest<T extends {
|
|||||||
return rows.filter((row) => {
|
return rows.filter((row) => {
|
||||||
if (row.kind !== "request_confirmation" || independent(row)) return true;
|
if (row.kind !== "request_confirmation" || independent(row)) return true;
|
||||||
if (newestByGroup.get(`${row.issueId}:${row.kind}`)?.id !== row.id) return false;
|
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();
|
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.
|
// Cancelled/failed replacements do not settle the ask, so an older card returns.
|
||||||
const SETTLED_CONFIRMATION_STATUSES = ["accepted", "rejected", "answered", "expired"] as const;
|
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[]) {
|
export async function loadNewestSettledConfirmationAt(db: Db, companyId: string, issueIds: string[]) {
|
||||||
const ids = [...new Set(issueIds)];
|
const ids = [...new Set(issueIds)];
|
||||||
if (ids.length === 0) return new Map<string, Date>();
|
if (ids.length === 0) return new Map<string, Date>();
|
||||||
const rows = await db.select({
|
const rows = await db.select({
|
||||||
issueId: issueThreadInteractions.issueId,
|
issueId: issueThreadInteractions.issueId,
|
||||||
|
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
||||||
|
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
||||||
createdAt: sql<string>`max(${issueThreadInteractions.createdAt})::text`,
|
createdAt: sql<string>`max(${issueThreadInteractions.createdAt})::text`,
|
||||||
}).from(issueThreadInteractions).where(and(
|
}).from(issueThreadInteractions).where(and(
|
||||||
eq(issueThreadInteractions.companyId, companyId),
|
eq(issueThreadInteractions.companyId, companyId),
|
||||||
@@ -70,8 +85,9 @@ export async function loadNewestSettledConfirmationAt(db: Db, companyId: string,
|
|||||||
inArray(issueThreadInteractions.status, [...SETTLED_CONFIRMATION_STATUSES]),
|
inArray(issueThreadInteractions.status, [...SETTLED_CONFIRMATION_STATUSES]),
|
||||||
sql`jsonb_typeof(${issueThreadInteractions.payload}->'qualityReview') is distinct from 'object'`,
|
sql`jsonb_typeof(${issueThreadInteractions.payload}->'qualityReview') is distinct from 'object'`,
|
||||||
sql`jsonb_typeof(${issueThreadInteractions.payload}->'toolAction') is distinct from 'object'`,
|
sql`jsonb_typeof(${issueThreadInteractions.payload}->'toolAction') is distinct from 'object'`,
|
||||||
)).groupBy(issueThreadInteractions.issueId);
|
sql`jsonb_typeof(${issueThreadInteractions.payload}->'secretProposal') is distinct from 'object'`,
|
||||||
return new Map(rows.map((row) => [row.issueId, new Date(row.createdAt)]));
|
)).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) {
|
export function readRunIssueId(contextSnapshot: Record<string, unknown> | null) {
|
||||||
|
|||||||
@@ -1179,7 +1179,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||||||
status: issueThreadInteractions.status,
|
status: issueThreadInteractions.status,
|
||||||
title: issueThreadInteractions.title,
|
title: issueThreadInteractions.title,
|
||||||
summary: issueThreadInteractions.summary,
|
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,
|
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
||||||
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
||||||
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
||||||
|
|||||||
Reference in new issue
Block a user