diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index daa556d4231..ed2ec590efd 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -886,6 +886,17 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { requestingAgentId: agentId, requestingAgentName: "ConnectionRequester", phase: "authorizing", }, }).returning(); + // A second pending request on the same issue asks for an AI account. The + // expiry must tell the two apart: only the AI one blocks the run. + const [aiInteraction] = await db.insert(issueThreadInteractions).values({ + companyId, issueId: issue.id, kind: "connection_intent", status: "pending", + createdByAgentId: agentId, addresseeUserId: "local-board", + payload: { + version: 1, purpose: "ai", serviceSlug: "connection:claude_local", + serviceName: "Claude", requestingAgentId: agentId, + requestingAgentName: "ConnectionRequester", phase: "authorizing", + }, + }).returning(); const oauthState = randomUUID(); await db.insert(toolOauthStates).values({ state: oauthState, companyId, connectionId: connection!.id, issueId: issue.id, @@ -901,9 +912,22 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await expect(db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction!.id))) .resolves.toEqual([expect.objectContaining({ status: "expired", - result: { version: 1, outcome: "expired", reason: "The task assignment changed" }, + result: { + version: 1, + outcome: "expired", + reason: "The task was reassigned before the connection request was answered, so the Notion connection is still not connected. Reconnect it or choose an available connection.", + }, resolvedAt: expect.any(Date), })]); + await expect(db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, aiInteraction!.id))) + .resolves.toEqual([expect.objectContaining({ + status: "expired", + result: { + version: 1, + outcome: "expired", + reason: "The task was reassigned before the connection request was answered, so the AI account is still not connected. Reconnect it or choose an available connection.", + }, + })]); await expect(db.select().from(toolOauthStates).where(eq(toolOauthStates.state, oauthState))).resolves.toEqual([]); }); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 96049788e73..015ba84cf45 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -4746,12 +4746,46 @@ export function issueThreadInteractionService( }, expireConnectionIntentsForOwnershipChange: async (issue: { id: string; companyId: string }) => { - const expired = await db.update(issueThreadInteractions).set({ - status: "expired", result: { version: 1, outcome: "expired", reason: "The task assignment changed" }, - resolvedAt: now(), updatedAt: now(), - }).where(and(eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - eq(issueThreadInteractions.kind, "connection_intent"), eq(issueThreadInteractions.status, "pending"))).returning(); - if (expired.length) await db.delete(toolOauthStates).where(inArray(toolOauthStates.interactionId, expired.map((row) => row.id))); + // A pending connection_intent asks for either an AI account or a tool + // account, and the two leave different things broken. One bulk write gave + // every row the AI wording, so an expired Notion request told the reader + // the AI account was missing. Read the pending rows first and name the + // subject each row actually asked for. + const pending = await db + .select({ id: issueThreadInteractions.id, payload: issueThreadInteractions.payload }) + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + eq(issueThreadInteractions.kind, "connection_intent"), + eq(issueThreadInteractions.status, "pending"), + )); + const expired: typeof issueThreadInteractions.$inferSelect[] = []; + for (const row of pending) { + const payload = row.payload as { purpose?: string; serviceName?: string } | null; + const subject = payload?.purpose === "ai" + ? "the AI account" + : `the ${payload?.serviceName?.trim() || "requested"} connection`; + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: { + version: 1, + outcome: "expired", + reason: `The task was reassigned before the connection request was answered, so ${subject} is still not connected. Reconnect it or choose an available connection.`, + }, + resolvedAt: now(), + updatedAt: now(), + }) + .where(and( + eq(issueThreadInteractions.id, row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + if (updated) expired.push(updated); + } + if (expired.length) await db.delete(toolOauthStates).where(inArray(toolOauthStates.interactionId, expired.map((item) => item.id))); return expired; }, expirePendingInteractionsForTerminalIssue: async ( diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx index 406eb0fcf63..f9ac280ec45 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx @@ -259,6 +259,18 @@ describe("ConnectionIntentInteractionBody states and audience", () => { expect(document.body.textContent).toContain(expected); }); + it("shows the stored reason on an expired request", () => { + // The server writes what actually expired the request and what to do next. + // A generic "no longer active" line left the reader with no next step. + const reason = "The task was reassigned before the connection request was answered, so the Notion connection is still not connected. Reconnect it or choose an available connection."; + renderBody({ + ...terminal("expired", "expired"), + result: { version: 1, outcome: "expired", reason }, + } as ConnectionIntentInteraction); + expect(document.body.textContent).toContain(reason); + expect(document.body.textContent).not.toContain("This request is no longer active."); + }); + it("shows non-addressees only the waiting state and never loads setup", () => { renderBody(pendingConnectionIntentInteraction, "other-user"); expect(document.body.textContent).toContain("Waiting for Carol"); diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx index 48a56139bed..a87af21f786 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx @@ -268,7 +268,10 @@ export function ConnectionIntentInteractionBody({ body: resultOutcome === "superseded" ? "This request was replaced. Use the latest connection card instead." - : "This request is no longer active.", + : // The server stores the reason an expiry happened and what to + // do next; without it the card only says the request is gone. + interaction.result?.reason?.trim() + || "This request is no longer active.", } : null; const StatusIcon = status?.icon;