diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index c84acf77f35..fdf4044fefb 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -41,6 +41,7 @@ import { import { errorHandler } from "../middleware/index.js"; import { attentionRoutes } from "../routes/attention.js"; import { attentionService } from "../services/attention.js"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; import { decisionHistoryService } from "../services/decision-history.js"; import { listAttentionExhaustedRuns } from "../services/attention-exhausted-runs.js"; import { agentService } from "../services/agents.js"; @@ -1540,6 +1541,170 @@ describeEmbeddedPostgres("attention service", () => { expect(interactionIds).not.toContain(olderConfirmationId); }); + it.each([ + ["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: `${prefix}-1`, + title: "Re-asked sign-off", + status: "in_review", + assigneeAgentId: workerId, + }); + const olderConfirmationId = randomUUID(); + const newerConfirmationId = randomUUID(); + await db.insert(issueThreadInteractions).values([ + { + id: olderConfirmationId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "none", + 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"), + }, + { + id: newerConfirmationId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "none", + createdByAgentId: reviewerId, + title: "Approve the plan (again)", + payload: { version: 1, prompt: "Approve the plan?" }, + createdAt: new Date("2026-11-05T12:05:00.000Z"), + updatedAt: new Date("2026-11-05T12:05:00.000Z"), + }, + ]); + + const pendingIds = async () => (await attentionService(db).list(companyId, { userId: "board-user" })).items + .filter((item) => item.sourceKind === "issue_thread_interaction") + .map((item) => item.subject.id); + expect(await pendingIds()).toEqual([newerConfirmationId]); + + if (outcome === "accept") { + // The chat card resolves the same canonical record through the same service. + await issueThreadInteractionService(db).acceptInteraction( + { id: issueId, companyId, projectId: null, goalId: null, status: "in_review" }, + newerConfirmationId, + {}, + { 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", + result: { version: 1, outcome: "stale_target" }, + resolvedAt: new Date("2026-11-05T12:10:00.000Z"), + updatedAt: new Date("2026-11-05T12:10:00.000Z"), + }).where(eq(issueThreadInteractions.id, newerConfirmationId)); + } + + expect(await pendingIds()).toEqual([]); + }); + + it("keeps a pending tool-action approval visible after a newer tool action is settled", async () => { + 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", 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.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: "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); + 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 }); diff --git a/server/src/services/attention-retention.ts b/server/src/services/attention-retention.ts index ddb74d682e5..378302faca3 100644 --- a/server/src/services/attention-retention.ts +++ b/server/src/services/attention-retention.ts @@ -2,7 +2,7 @@ import { and, eq, getTableColumns, inArray, isNull, sql, type SQL } from "drizzl import { agents, decisionRetention, decisions, heartbeatRuns, issueApprovals, issueRecoveryActions, issueThreadInteractions, issues, approvals, type Db } from "@paperclipai/db"; import { evaluateAgentInvokability } from "./agent-invokability.js"; -import { HUMAN_RECOVERY_OWNER_TYPES, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, listFailedRunsWithNewerRun, readRunIssueId, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js"; +import { HUMAN_RECOVERY_OWNER_TYPES, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, listFailedRunsWithNewerRun, loadNewestSettledConfirmationAt, readRunIssueId, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js"; import { decisionRetentionService, DEFAULT_DECISION_ARCHIVE_DAYS } from "./decision-retention.js"; import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js"; import { DELIVERY_QUEUES } from "./delivery-work-notifications.js"; @@ -116,7 +116,8 @@ export function attentionRetentionService(db: Db) { eq(issueThreadInteractions.companyId, companyId), inArray(issueThreadInteractions.issueId, confirmationIssueIds), eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.kind, "request_confirmation"), isNull(issueThreadInteractions.addresseeUserId))) : []; - const newest = new Set(collapsePendingConfirmationsToNewest(siblings.filter(boardAddressed)).map(row => row.id)); + const newest = new Set(collapsePendingConfirmationsToNewest(siblings.filter(boardAddressed), + await loadNewestSettledConfirmationAt(database, companyId, confirmationIssueIds)).map(row => row.id)); for (const row of rows) result.set(row.id, visibleIds.has(row.issueId) && boardAddressed(row) && (row.kind !== "request_confirmation" || newest.has(row.id))); } diff --git a/server/src/services/attention-source-eligibility.ts b/server/src/services/attention-source-eligibility.ts index 971ca5f1ee5..be7772f9e80 100644 --- a/server/src/services/attention-source-eligibility.ts +++ b/server/src/services/attention-source-eligibility.ts @@ -1,5 +1,5 @@ import { and, eq, gt, inArray, sql, type SQL } from "drizzle-orm"; -import { heartbeatRuns, issueRecoveryActions, type Db } from "@paperclipai/db"; +import { heartbeatRuns, issueRecoveryActions, issueThreadInteractions, type Db } from "@paperclipai/db"; export const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const; const OPEN_RECOVERY_ATTENTION_STATUSES = ["active", "escalated"] as const; @@ -11,14 +11,44 @@ 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)[key]); +} + +/** 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) { + // Truthy, not key presence: the summary read projects absent keys as JSON null. + return GOVERNED_CONFIRMATION_PAYLOAD_KEYS.some((key) => payloadFlag(payload, key)); +} + +/** + * 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, 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. + */ export function collapsePendingConfirmationsToNewest(rows: T[]) { - const independent = (row: T) => Boolean(row.payload && typeof row.payload === "object" && "qualityReview" in row.payload && row.payload.qualityReview); + addresseeUserId?: string | null; + addresseeAgentId?: string | null; +}>(rows: T[], newestSettledConfirmationAt: ReadonlyMap = new Map()) { + const independent = (row: T) => payloadFlag(row.payload, "qualityReview"); const newestByGroup = new Map(); for (const row of rows) { if (row.kind !== "request_confirmation" || independent(row)) continue; @@ -33,11 +63,35 @@ export function collapsePendingConfirmationsToNewest ( - row.kind !== "request_confirmation" - || independent(row) - || newestByGroup.get(`${row.issueId}:${row.kind}`)?.id === row.id - )); + 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 = governedAction(row.payload) ? undefined : newestSettledConfirmationAt.get(confirmationAudienceKey(row)); + return !settledAt || settledAt.getTime() <= row.createdAt.getTime(); + }); +} + +// 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 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(); + const rows = await db.select({ + issueId: issueThreadInteractions.issueId, + addresseeUserId: issueThreadInteractions.addresseeUserId, + addresseeAgentId: issueThreadInteractions.addresseeAgentId, + createdAt: sql`max(${issueThreadInteractions.createdAt})::text`, + }).from(issueThreadInteractions).where(and( + eq(issueThreadInteractions.companyId, companyId), + inArray(issueThreadInteractions.issueId, ids), + eq(issueThreadInteractions.kind, "request_confirmation"), + inArray(issueThreadInteractions.status, [...SETTLED_CONFIRMATION_STATUSES]), + sql`jsonb_typeof(${issueThreadInteractions.payload}->'qualityReview') 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)])); } export function readRunIssueId(contextSnapshot: Record | null) { diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index bbbf505f480..44f2c844cf1 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -1,4 +1,4 @@ -import { recoveryActionAttentionCondition, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, readRunIssueId, listFailedRunsWithNewerRun, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js"; +import { recoveryActionAttentionCondition, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, loadNewestSettledConfirmationAt, readRunIssueId, listFailedRunsWithNewerRun, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js"; import { approvalExecutionIssueCondition, firstApprovalIssueId } from "./approval-visibility.js"; import { and, asc, desc, eq, inArray, isNull, notInArray, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -1179,7 +1179,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions status: issueThreadInteractions.status, title: issueThreadInteractions.title, summary: issueThreadInteractions.summary, - payload: summaryOnly ? sql>`jsonb_build_object('target', ${issueThreadInteractions.payload}->'target', 'qualityReview', ${issueThreadInteractions.payload}->'qualityReview')` : issueThreadInteractions.payload, + payload: summaryOnly ? sql>`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, @@ -1223,7 +1223,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions !evaluateAgentInvokability(companyAgentMap.get(row.addresseeAgentId), companyAgentRows).invokable) && (row.addresseeUserId === null || row.addresseeUserId === options.userId) ); - const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows); + const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows, await loadNewestSettledConfirmationAt( + db, companyId, boardInteractionRows.filter((row) => row.kind === "request_confirmation").map((row) => row.issueId), + )); const [interactionIssueMap, interactionImageMap, interactionPlanDocumentMap] = await Promise.all([ // A pending human request remains actionable in a persistent chat even // though its idle container is intentionally absent from work queues.