diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index 8982e83898d..6be15a2b0d4 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -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 }); diff --git a/server/src/services/attention-source-eligibility.ts b/server/src/services/attention-source-eligibility.ts index 4505a31bbd5..bfe3ed1adb8 100644 --- a/server/src/services/attention-source-eligibility.ts +++ b/server/src/services/attention-source-eligibility.ts @@ -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)[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(rows: T[], newestSettledConfirmationAt: ReadonlyMap = new Map()) { const independent = (row: T) => payloadFlag(row.payload, "qualityReview"); const newestByGroup = new Map(); @@ -48,7 +61,7 @@ export function collapsePendingConfirmationsToNewest { 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(); 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), @@ -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 | null) { diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index 1f24e5d5881..69843682c72 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -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')` : issueThreadInteractions.payload, addresseeAgentId: issueThreadInteractions.addresseeAgentId, addresseeUserId: issueThreadInteractions.addresseeUserId, createdByAgentId: issueThreadInteractions.createdByAgentId,