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:
DottaandClaude Opus 5.5 committed 2026-11-06 12:11:49 -06:00
1 parent ebe8e0c0ae
commit 9245bd7b17
3 files changed
+109 -15

No files matched your search

+86 -8
View File
@@ -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) {
+1 -1
View File
@@ -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,