mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
fix(attention): treat app-connection approvals as governed confirmations
An accepted connectionAuthorization card answers an app connection, not an ordinary sign-off. It no longer settles older ordinary confirmations, and the summary payload keeps the flag. The governed keys now match the set used in issue-question-context and the other confirmation readers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d23b0f3aff
commit
a36923c7fc
3 files changed
+19
-12
No files matched your search
@@ -1649,16 +1649,19 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
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 });
|
||||
it.each([
|
||||
["secret proposal", "ATS", { secretProposal: { proposalId: "11111111-1111-4111-8111-111111111111" } }],
|
||||
["app connection", "ATK", { connectionAuthorization: { connectionId: "22222222-2222-4222-8222-222222222222" } }],
|
||||
] as const)("does not let a settled %s hide an older ordinary confirmation", async (_label, prefix, governed) => {
|
||||
const { companyId, workerId, reviewerId } = await seedCompany(prefix);
|
||||
const issueId = await insertIssue({ companyId, identifier: `${prefix}-1`, title: "Governed card 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() } },
|
||||
{ ...base, id: randomUUID(), status: "accepted", createdByAgentId: reviewerId, title: "Governed action",
|
||||
payload: { version: 1, prompt: "Allow this?", ...governed },
|
||||
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);
|
||||
|
||||
@@ -19,9 +19,13 @@ 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. */
|
||||
/** Payload keys that bind a confirmation to one specific action (tool call, secret, app connection). */
|
||||
const GOVERNED_CONFIRMATION_PAYLOAD_KEYS = ["toolAction", "secretProposal", "connectionAuthorization"] as const;
|
||||
|
||||
/** Governed confirmations gate one action; answering one never answers an ordinary ask, and vice versa. */
|
||||
function governedAction(payload: unknown) {
|
||||
return payloadFlag(payload, "toolAction") || payloadFlag(payload, "secretProposal");
|
||||
// Truthy, not key presence: the summary read projects absent keys as JSON null.
|
||||
return GOVERNED_CONFIRMATION_PAYLOAD_KEYS.some((key) => payloadFlag(payload, key));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,8 +33,9 @@ function governedAction(payload: unknown) {
|
||||
* 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 and secret-proposal approvals gate one specific
|
||||
* action, so they neither hide nor are hidden by a settled confirmation.
|
||||
* independent; tool-action, secret-proposal and app-connection 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.
|
||||
*/
|
||||
@@ -84,8 +89,7 @@ export async function loadNewestSettledConfirmationAt(db: Db, companyId: string,
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
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'`,
|
||||
sql`jsonb_typeof(${issueThreadInteractions.payload}->'secretProposal') is distinct from 'object'`,
|
||||
sql`not (${issueThreadInteractions.payload} ?| array['toolAction', 'secretProposal', 'connectionAuthorization'])`,
|
||||
)).groupBy(issueThreadInteractions.issueId, issueThreadInteractions.addresseeUserId, issueThreadInteractions.addresseeAgentId);
|
||||
return new Map(rows.map((row) => [confirmationAudienceKey(row), new Date(row.createdAt)]));
|
||||
}
|
||||
|
||||
@@ -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', 'toolAction', ${issueThreadInteractions.payload}->'toolAction', 'secretProposal', ${issueThreadInteractions.payload}->'secretProposal')` : 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', 'connectionAuthorization', ${issueThreadInteractions.payload}->'connectionAuthorization')` : issueThreadInteractions.payload,
|
||||
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
||||
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
||||
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
||||
|
||||
Reference in new issue
Block a user