From 2ca0d26a99c0d7e8db055b784e50b732c242b8f4 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:36:01 -0500 Subject: [PATCH] fix(connections): recover missing personal AI credentials in chat (#15376) Co-Authored-By: Paperclip --- doc/SPEC-implementation.md | 7 ++ doc/SPEC.md | 2 + doc/connections/AI-CONNECTIONS.md | 11 +++ server/src/__tests__/ai-auth-failure.test.ts | 49 ++++++++++ server/src/__tests__/ai-connections.test.ts | 91 ++++++++++++++++++- server/src/routes/agents.ts | 10 +- server/src/services/ai-auth-failure.ts | 48 +++++++++- .../services/connection-intent-delivery.ts | 4 +- server/src/services/connection-intents.ts | 13 +-- server/src/services/run-liveness.ts | 4 +- .../ai-personal-connection-recovery.spec.ts | 76 ++++++++++++++++ tests/e2e/fixtures/ai-connection-provider.mjs | 16 ++++ tests/e2e/playwright.config.ts | 2 +- ui/src/components/TaskChatThread.test.tsx | 17 ++++ ui/src/components/TaskChatThread.tsx | 7 +- .../AgentProviderConnection.test.tsx | 1 - .../new-agent/AgentProviderConnection.tsx | 3 +- .../ConnectionIntentInteractionBody.test.tsx | 11 ++- .../ConnectionIntentInteractionBody.tsx | 22 +++-- 19 files changed, 359 insertions(+), 35 deletions(-) create mode 100644 server/src/__tests__/ai-auth-failure.test.ts create mode 100644 tests/e2e/ai-personal-connection-recovery.spec.ts create mode 100644 tests/e2e/fixtures/ai-connection-provider.mjs diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index f3ce1168db3..0d7715e854e 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1753,6 +1753,13 @@ Legacy agents retain their authentication until validated adoption. See [AI Connections](connections/AI-CONNECTIONS.md) for company isolation, compatible methods, lifecycle, runtime enforcement, and migration details. +Missing personal AI credentials detected before adapter dispatch also produce +the inline connection card. Every missing binding must belong to the same +compatible AI provider. The responsible user connects their own account and +explicitly adopts Connections; another user's onboarding key is never reused. +Acceptance resumes only the matching configuration-blocked task through durable +continuation delivery. Unrelated configuration gaps retain operator recovery. + The selected AI connection supports an on-demand usage probe through the common connection service, independent of legacy/native execution. The board usage endpoint rechecks company membership and the credential's human audience before diff --git a/doc/SPEC.md b/doc/SPEC.md index e32f9eef561..5b949cc70f8 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -37,6 +37,8 @@ outcome. See [the implementation contract](SPEC-implementation.md#124-connection Model authentication failures also surface a provider-specific Connections card on the task immediately after failure. Users can reconnect inline and resume; legacy agents keep their authentication until an explicit, validated adoption. +Missing personal AI credentials found before a run starts use the same card. +The responsible user connects their own account inside the task and continues. See [AI Connections](connections/AI-CONNECTIONS.md). AI connections also expose an on-demand, credential-scoped usage probe. It diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index fe9e0584b3c..2e74412af1b 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -237,6 +237,17 @@ repairs the connection. Unsupported providers and failures that could not create a card retain their existing recovery path. Tool permission errors and provider quota failures do not request model authentication. +Pre-dispatch `configuration_incomplete` failures also show this card when every +missing binding is a personal `user_secret_ref` for the same compatible AI +provider. This includes a teammate who has no value for an onboarding +`ANTHROPIC_API_KEY` definition. The card asks that person to connect their own +account. It does not use another teammate's secret. Mixed gaps, company secrets, +and unrelated tool credentials keep the existing operator recovery path. +After explicit, validated adoption, the durable delivery reopens only the +blocked task whose latest failure and active configuration recovery still match +the card. A newer failure, reassignment, manual hold, or restricted external chat +does not resume through an old card. + An attributed managed credential is marked as needing reauthorization only if its stored generation still matches the failed run. Late failures cannot invalidate a refreshed or reconnected credential. Repair preserves the selected diff --git a/server/src/__tests__/ai-auth-failure.test.ts b/server/src/__tests__/ai-auth-failure.test.ts new file mode 100644 index 00000000000..5e38d45dd5e --- /dev/null +++ b/server/src/__tests__/ai-auth-failure.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { aiBindingForAuthRecovery, isAiAuthenticationBlocked, isAiAuthenticationRepairable } from "../services/ai-auth-failure.js"; +import { classifyRunLiveness } from "../services/run-liveness.js"; + +const missingClaudeKey = { + bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY", + configPath: "env.ANTHROPIC_API_KEY", errorCode: "user_secret_missing", + consumerType: "agent", responsibleUserId: "new-teammate", +}; +const failure = (missingBindings: unknown, reason = "secret_binding_missing") => ({ + errorCode: "configuration_incomplete", + resultJson: { configurationIncomplete: { reason, missingBindings } }, +}); + +describe("personal AI credential preflight recovery", () => { + it.each(["user_secret_missing", "secret_inactive"])("offers Claude setup for %s without adopting another provider", errorCode => { + const run = failure([{ ...missingClaudeKey, errorCode }]); + expect(isAiAuthenticationRepairable(run)).toBe(true); + expect(aiBindingForAuthRecovery("claude_local", {}, run)).toEqual({ + provider: "anthropic", method: "subscription", mode: "responsible_user", + }); + expect(aiBindingForAuthRecovery("codex_local", {}, run)).toBeUndefined(); + expect(isAiAuthenticationBlocked({ ...run, livenessState: "failed" })).toBe(false); + expect(isAiAuthenticationBlocked({ ...run, livenessState: "blocked" })).toBe(true); + }); + + it.each([ + ["tool credential", [{ ...missingClaudeKey, envKey: "GITHUB_TOKEN", configPath: "env.GITHUB_TOKEN" }]], + ["company secret", [{ ...missingClaudeKey, bindingType: "secret_ref" }]], + ["unknown error", [{ ...missingClaudeKey, errorCode: "user_secret_definition_missing" }]], + ["adapter field", [{ ...missingClaudeKey, configPath: "apiKey" }]], + ["additional tool blocker", [missingClaudeKey, { ...missingClaudeKey, envKey: "GITHUB_TOKEN", configPath: "env.GITHUB_TOKEN" }]], + ["different providers", [missingClaudeKey, { ...missingClaudeKey, envKey: "OPENAI_API_KEY", configPath: "env.OPENAI_API_KEY" }]], + ["empty evidence", []], ["malformed evidence", [null]], ["missing evidence", null], + ])("keeps %s on the existing configuration recovery path", (_name, bindings) => { + expect(isAiAuthenticationRepairable(failure(bindings))).toBe(false); + expect(isAiAuthenticationBlocked({ ...failure(bindings), livenessState: "blocked" })).toBe(false); + }); + + it("only classifies a failed preflight as an inline wait after the card is persisted", () => { + const input = { ...failure([missingClaudeKey]), runStatus: "failed", issue: { status: "blocked" }, + stdoutExcerpt: null, stderrExcerpt: null, error: null, continuationAttempt: 0, evidence: null }; + expect(classifyRunLiveness(input).livenessState).toBe("failed"); + expect(classifyRunLiveness({ ...input, authenticationRepairRequested: true })).toMatchObject({ + livenessState: "blocked", nextAction: "Complete the connection request to continue this task", + }); + expect(classifyRunLiveness({ ...input, ...failure([]), authenticationRepairRequested: true }).livenessState).toBe("failed"); + }); +}); diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 378fc3669ea..14550483b72 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -19,6 +19,7 @@ import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "../services import { execute as executeGemini, testEnvironment as testGeminiEnvironment } from "@paperclipai/adapter-gemini-local/server"; import { toolAccessService } from "../services/tool-access.js"; import { secretService } from "../services/secrets.js"; +import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.js"; import { aiConnectionBindingSchema, connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared"; import express from "express"; import request from "supertest"; @@ -378,6 +379,73 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" await db.insert(heartbeatRuns).values({ companyId, agentId: id, status: "succeeded", responsibleUserId: "alice", contextSnapshot: { issueId }, createdAt: new Date(Date.now() + 1000) }); expect(await intents.requestForRunAuthFailure(runId)).toBeNull(); }); + + it("repairs a teammate's missing onboarding key with their own inline AI connection", async () => { + const userId = `new-teammate-${randomUUID()}`; + const id = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const vault = secretService(db); + const definition = await vault.createUserSecretDefinition(companyId, { + key: `ANTHROPIC_API_KEY.${randomUUID()}`, name: "ANTHROPIC_API_KEY for onboarding", + }, { userId: "alice" }); + const ownerSecret = await vault.createCurrentUserSecretValue(companyId, "alice", { + definitionId: definition.id, value: "fixture-original-owner-key", + }, { userId: "alice" }); + const adapterConfig = { env: { ANTHROPIC_API_KEY: { + type: "user_secret_ref", key: definition.key, version: "latest", required: true, + } } }; + await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" }); + const [agent] = await db.insert(agents).values({ id, companyId, name: "Chief of Staff", adapterType: "claude_local", adapterConfig }).returning(); + await vault.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: id }, adapterConfig.env); + await db.insert(issues).values({ id: issueId, companyId, title: "Chat with Chief of Staff", status: "blocked", assigneeAgentId: id, responsibleUserId: userId }); + const failure = await resolveExecutionRunAdapterConfig({ + companyId, agentId: id, adapterType: "claude_local", issueId, + responsibleUserId: userId, executionRunConfig: adapterConfig, projectEnv: null, secretsSvc: vault, + }).catch(error => error); + expect(failure).toMatchObject({ code: "configuration_incomplete", resultJson: { + configurationIncomplete: { reason: "secret_binding_missing", missingBindings: [ + expect.objectContaining({ bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY", errorCode: "user_secret_missing", responsibleUserId: userId }), + ] }, + } }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: id, status: "failed", + errorCode: failure.code, resultJson: failure.resultJson, responsibleUserId: userId, contextSnapshot: { issueId } }); + await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId, + kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `missing-key:${issueId}`, + nextAction: "Connect your AI account", ownerType: "board", evidence: { latestRunId: runId } }); + const intents = connectionIntentService(db); + const card = await intents.requestForRunAuthFailure(runId); + expect(card).toMatchObject({ service: "anthropic", state: "needs_user_action" }); + expect((await intents.setupOptions(card!.interactionId!))).toMatchObject({ + aiConnectionRequiresAdoption: true, aiConnection: { provider: "anthropic", mode: "responsible_user" }, + interaction: { addresseeUserId: userId, sourceRunId: runId }, + }); + expect((await intents.requestForRunAuthFailure(runId))?.interactionId).toBe(card!.interactionId); + expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined(); + + const account = await create(userId, "Teammate's Claude"); + const setup = await intents.setupOptions(card!.interactionId!); + await intents.complete(card!.interactionId!, account.connectionId, userId, { + validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection! }, + }); + const wakeup = vi.fn(async (_agentId, opts) => { + await db.insert(agentWakeupRequests).values({ companyId, agentId: id, source: "automation", status: "queued", idempotencyKey: opts.idempotencyKey }); + return null; + }); + const delivery = connectionIntentDeliveryService(db, { wakeup } as never); + await delivery.deliver(card!.interactionId!); + await delivery.deliver(card!.interactionId!); + expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("in_progress"); + expect(wakeup).toHaveBeenCalledTimes(1); + expect(wakeup).toHaveBeenCalledWith(id, expect.objectContaining({ contextSnapshot: expect.objectContaining({ forceFreshSession: true }) })); + expect(await issueRecoveryActionService(db).getActiveForIssue(companyId, issueId)).toBeNull(); + expect((await db.select().from(companySecrets).where(eq(companySecrets.id, ownerSecret.id)))[0]).toMatchObject({ ownerUserId: "alice", latestVersion: 1, status: "active" }); + const runtime = await prepareManagedAiRuntime(db, { companyId, agentId: id, responsibleUserId: userId, adapterType: "claude_local", binding: setup.aiConnection!, config: adapterConfig }); + try { + expect(runtime.config.env).toMatchObject({ ANTHROPIC_API_KEY: "fixture-Teammate's Claude" }); + expect(runtime.attribution.grantId).toBe(account.grantId); + } finally { await runtime.cleanup(); } + }, 30000); it.each([ ["anthropic", "claude_local", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], ["openai", "codex_local", "CODEX_HOME", "OPENAI_API_KEY"], @@ -1022,6 +1090,13 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" await db.insert(agents).values({ id, companyId, name: "Runner adoption", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } }); const account = await service.save(companyId, "alice", { provider: "openai", method: "api_key", ownership: "personal", name: "Runner adoption account", apiKey: "fixture-adoption-key", agentIds: inline ? [] : [id], allAgents: false }, "fixture-adoption-key"); await service.setDefault(companyId, "alice", account.grantId); + const tryDeliver = vi.fn(async (interactionId: string) => { + // Dispatch observes committed adoption, never a partially saved choice. + expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].status).toBe("accepted"); + expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toMatchObject({ mode: "responsible_user" }); + }); + const deliveryModule = await import("../services/connection-intent-delivery.js"); + const delivery = vi.spyOn(deliveryModule, "connectionIntentDeliveryService").mockReturnValue({ tryDeliver } as never); let interactionId: string | undefined; if (inline) { const issueId = randomUUID(); @@ -1052,6 +1127,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" resolveTarget.mockResolvedValueOnce(null); const unavailable = await adopt(); expect(unavailable.status, JSON.stringify(unavailable.body)).toBe(422); + expect(tryDeliver).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled(); expect(providerRequest).not.toHaveBeenCalled(); expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined(); @@ -1068,6 +1144,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" try { const failed = await adopt(); expect(failed.status).toBe(500); + expect(tryDeliver).not.toHaveBeenCalled(); await assertUnchanged(); } finally { await db.execute(sql.raw("DROP TRIGGER reject_atomic_adoption ON issue_thread_interactions")); @@ -1083,6 +1160,8 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" expect(saved.body.status).toBe("accepted"); expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.connectionId))).toHaveLength(1); expect((await adopt()).status).toBe(200); + expect(tryDeliver).toHaveBeenCalledTimes(2); + expect(tryDeliver).toHaveBeenCalledWith(interactionId); } expect(acquire).toHaveBeenCalledWith(expect.objectContaining({ companyId, environment: expect.objectContaining({ id: environment.id }) })); expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target, config: expect.objectContaining({ provider: "codex", model: "gpt-5.6-sol", managedAiConnection: expect.any(Object) }) })); @@ -1093,7 +1172,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" expect(release).toHaveBeenCalledTimes(inline ? 3 : 2); expect(JSON.stringify(saved.body)).not.toContain("fixture-adoption-key"); } finally { - providerRequest.mockRestore(); probe.mockRestore(); runtime.mockRestore(); resolveTarget.mockRestore(); + delivery.mockRestore(); providerRequest.mockRestore(); probe.mockRestore(); runtime.mockRestore(); resolveTarget.mockRestore(); await settings.update({ defaultEnvironmentId: previous.defaultEnvironmentId }); } }); @@ -1141,8 +1220,10 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" describe("AI connection recovery delivery", () => { - it.each(["restored", "newer failure", "different blocker", "revoked again", "closed task"])( - "continues only the repaired source failure: %s", async (scenario) => { + it.each(["ai_connection_unavailable", "secret_binding_missing"].flatMap(reason => + ["restored", "newer failure", "different blocker", "revoked again", "closed task"].map(scenario => [reason, scenario]), + ))( + "continues only the repaired %s source failure: %s", async (reason, scenario) => { const userId = `recovery-${randomUUID()}`; const recoveringAgentId = randomUUID(); const issueId = randomUUID(); @@ -1155,7 +1236,9 @@ describe("AI connection recovery delivery", () => { const pending = await intents.request({ sub: recoveringAgentId, company_id: companyId, run_id: failedRunId, responsible_user_id: userId }, "anthropic", { purpose: "ai" }); const account = await create(userId, `Recovered ${scenario}`); expect((await intents.setupOptions(pending.interactionId!)).existingConnections.map(connection => connection.id)).toEqual([account.connectionId]); - await db.update(heartbeatRuns).set({ status: "failed", errorCode: "configuration_incomplete", resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable" } } }).where(eq(heartbeatRuns.id, failedRunId)); + await db.update(heartbeatRuns).set({ status: "failed", errorCode: "configuration_incomplete", resultJson: { configurationIncomplete: { reason, + ...(reason === "secret_binding_missing" ? { missingBindings: [{ bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY", configPath: "env.ANTHROPIC_API_KEY", errorCode: "user_secret_missing" }] } : {}), + } } }).where(eq(heartbeatRuns.id, failedRunId)); await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId)); await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId, kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `ai:${issueId}`, nextAction: "Reconnect", ownerType: "board", evidence: { latestRunId: failedRunId } }); await intents.complete(pending.interactionId!, account.connectionId, userId); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5373891b489..347a21a5e6e 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2,6 +2,7 @@ import { aiRoutingHarness } from "@paperclipai/shared"; import { agentIdentityService } from "../services/agent-identity.js"; import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js"; import { connectionIntentService } from "../services/connection-intents.js"; +import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js"; import { completeConnectionIntentSchema } from "@paperclipai/shared"; import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js"; import { pipeline } from "node:stream/promises"; @@ -5536,6 +5537,7 @@ export function agentRoutes( if (!userId || loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can adopt this connection"); const connectionId = req.body.connectionId as string; if (loaded.interaction.status === "accepted" && loaded.interaction.result?.connectionId === connectionId) { + await connectionIntentDeliveryService(db, heartbeat).tryDeliver(interactionId); res.json(loaded.interaction); return; } @@ -5546,9 +5548,13 @@ export function agentRoutes( // The binding, install, audit, and card resolution commit together below. const validatedConnection = await validateManagedAgentBinding(req, agent.companyId, agent.id, agent.adapterType, agent.adapterConfig, setup.aiConnection, agent.defaultEnvironmentId, true, true); if (validatedConnection?.connectionId !== connectionId) throw conflict("This is no longer the selected account. Reload the task and try again."); - res.json(await intents.complete(interactionId, connectionId, userId, { + const completed = await intents.complete(interactionId, connectionId, userId, { validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection }, - })); + }); + // Dispatch the committed continuation now. The persisted delivery remains + // available to the sweeper if this process exits or dispatch is deferred. + await connectionIntentDeliveryService(db, heartbeat).tryDeliver(interactionId); + res.json(completed); }); router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => { diff --git a/server/src/services/ai-auth-failure.ts b/server/src/services/ai-auth-failure.ts index 5fb8c03e2e9..276a061aa55 100644 --- a/server/src/services/ai-auth-failure.ts +++ b/server/src/services/ai-auth-failure.ts @@ -1,4 +1,43 @@ -import { AI_PROVIDERS, AI_CONNECTION_CAPABILITIES, isAiConnectionCompatible, type AiConnectionBinding } from "@paperclipai/shared"; +import { AI_PROVIDERS, AI_CONNECTION_CAPABILITIES, isAiConnectionCompatible, type AiConnectionBinding, type AiProvider } from "@paperclipai/shared"; + +type AuthenticationFailure = { + errorCode?: string | null; + resultJson?: unknown; +}; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : null; +} + +/** A personal model credential gap is repairable without changing other secrets. */ +function missingPersonalAiCredentialProvider(run: AuthenticationFailure): AiProvider | undefined { + if (run.errorCode !== "configuration_incomplete") return undefined; + const gap = record(record(run.resultJson)?.configurationIncomplete); + if (gap?.reason !== "secret_binding_missing" || !Array.isArray(gap.missingBindings) || !gap.missingBindings.length) return undefined; + const missingBindings = gap.missingBindings; + return AI_PROVIDERS.find(provider => { + const keys = new Set(Object.values(AI_CONNECTION_CAPABILITIES[provider].methods).map(method => method.envKey)); + return missingBindings.every((value: unknown) => { + const binding = record(value); + return binding?.bindingType === "user_secret_ref" + && ["user_secret_missing", "secret_inactive"].includes(String(binding.errorCode)) + && typeof binding.envKey === "string" && keys.has(binding.envKey) + && binding.configPath === `env.${binding.envKey}`; + }); + }); +} + +export function isAiConnectionConfigurationFailure(run: AuthenticationFailure): boolean { + return run.errorCode === "configuration_incomplete" && ( + record(record(run.resultJson)?.configurationIncomplete)?.reason === "ai_connection_unavailable" + || Boolean(missingPersonalAiCredentialProvider(run)) + ); +} + +export function isAiAuthenticationRepairable(run: AuthenticationFailure): boolean { + return isAiAuthenticationFailure(run.errorCode) || isAiConnectionConfigurationFailure(run); +} /** Provider authentication signals only. Tool authorization and quotas need different repairs. */ export function isAiAuthenticationFailure(code: string | null | undefined): boolean { @@ -9,15 +48,18 @@ export function isAiAuthenticationFailure(code: string | null | undefined): bool } /** Only a persisted blocked classification confirms that an inline repair owns recovery. */ -export function isAiAuthenticationBlocked(run: { errorCode?: string | null; livenessState?: string | null } | null | undefined): boolean { - return run?.livenessState === "blocked" && isAiAuthenticationFailure(run.errorCode); +export function isAiAuthenticationBlocked(run: (AuthenticationFailure & { livenessState?: string | null }) | null | undefined): boolean { + return run?.livenessState === "blocked" && isAiAuthenticationRepairable(run); } export function aiBindingForAuthRecovery( adapterType: string, config: Record, + failure?: AuthenticationFailure, ): AiConnectionBinding | undefined { + const missingProvider = failure ? missingPersonalAiCredentialProvider(failure) : undefined; for (const provider of AI_PROVIDERS) { + if (missingProvider && missingProvider !== provider) continue; const binding = { provider, method: AI_CONNECTION_CAPABILITIES[provider].methods.subscription ? "subscription" : "api_key", mode: "responsible_user" } as const; if (isAiConnectionCompatible(binding, adapterType, config.model, config.provider, config.acpxAgent)) return binding; } diff --git a/server/src/services/connection-intent-delivery.ts b/server/src/services/connection-intent-delivery.ts index 4447eaf5a11..540524bf4ea 100644 --- a/server/src/services/connection-intent-delivery.ts +++ b/server/src/services/connection-intent-delivery.ts @@ -1,4 +1,5 @@ import { connectionIntentService } from "./connection-intents.js"; +import { isAiConnectionConfigurationFailure } from "./ai-auth-failure.js"; import { and, eq, isNull, lte, asc, notInArray, desc, sql } from "drizzle-orm"; import { connectionIntentDeliveries, issueThreadInteractions, issues, agentWakeupRequests, companyMemberships, heartbeatRuns, chatConversations, chatEndpoints, type Db } from "@paperclipai/db"; import type { heartbeatService } from "./heartbeat.js"; @@ -79,9 +80,8 @@ export function connectionIntentDeliveryService(db: Db, heartbeat: Pick>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${issue.id}`, )).orderBy(desc(heartbeatRuns.createdAt)).limit(1); - const gap = latest?.resultJson?.configurationIncomplete as { reason?: string } | undefined; if (latest?.id !== loaded.interaction.sourceRunId || latest.status !== "failed" - || latest.errorCode !== "configuration_incomplete" || gap?.reason !== "ai_connection_unavailable") return null; + || !isAiConnectionConfigurationFailure(latest)) return null; // Restricted external chat retries require their original chat provenance. // Their existing Try again path owns that authorization and delivery. const [restrictedChat] = await tx.select({ id: chatConversations.id }).from(chatConversations) diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 175a2228d89..bcb808fdaf2 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -6,7 +6,7 @@ import { createHash } from "node:crypto"; import { logActivity } from "./activity-log.js"; import { aiConnectionService } from "./ai-connections.js"; import { aiConnectionBindingSchema, aiConnectionRouterBindingSchema } from "@paperclipai/shared"; -import { aiBindingForAuthRecovery, isAiAuthenticationFailure } from "./ai-auth-failure.js"; +import { aiBindingForAuthRecovery, isAiAuthenticationFailure, isAiAuthenticationRepairable } from "./ai-auth-failure.js"; import { and, eq, desc, isNull, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { @@ -162,6 +162,7 @@ export function connectionIntentService(db: Db) { agentId: heartbeatRuns.agentId, status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode, + resultJson: heartbeatRuns.resultJson, responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId, contextSnapshot: heartbeatRuns.contextSnapshot, @@ -176,7 +177,7 @@ export function connectionIntentService(db: Db) { || run.agentId !== claims.sub || (!run.activeIdentityContextId && run.responsibleUserId !== claims.responsible_user_id) ) throw forbidden("Runtime tool token does not match its heartbeat run"); - if (failedAuthRun ? run.status !== "failed" || !isAiAuthenticationFailure(run.errorCode) : run.status !== "running") { + if (failedAuthRun ? run.status !== "failed" || !isAiAuthenticationRepairable(run) : run.status !== "running") { throw forbidden("Runtime tool token is no longer active"); } if (run.activeIdentityContextId && !failedAuthRun) { @@ -854,8 +855,8 @@ export function connectionIntentService(db: Db) { eq(heartbeatRuns.agentId, payload.requestingAgentId), )); const [agent] = await db.select().from(agents).where(and(eq(agents.id, payload.requestingAgentId), eq(agents.companyId, loaded.issue.companyId))); - if (source?.status === "failed" && isAiAuthenticationFailure(source.errorCode) && !source.contextSnapshot?.aiConnection && agent && !agent.runtimeConfig.aiConnection) { - managed = await managedAgent(loaded.issue.companyId, agent.id, app.slug, { fallback: aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig) }); + if (source?.status === "failed" && isAiAuthenticationRepairable(source) && !source.contextSnapshot?.aiConnection && agent && !agent.runtimeConfig.aiConnection) { + managed = await managedAgent(loaded.issue.companyId, agent.id, app.slug, { fallback: aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig, source) }); } } if (payload.purpose === "ai" && !managed) throw conflict("The agent’s AI configuration changed. Start a new execution."); @@ -1165,7 +1166,7 @@ export function connectionIntentService(db: Db) { // Controller-only entry point. Runtime tokens still require a running run. requestForRunAuthFailure: async (runId: string) => { const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); - if (!run || run.status !== "failed" || !isAiAuthenticationFailure(run.errorCode) || !run.responsibleUserId) return null; + if (!run || run.status !== "failed" || !isAiAuthenticationRepairable(run) || !run.responsibleUserId) return null; const context = await loadRunContext({ sub: run.agentId, company_id: run.companyId, run_id: run.id, responsible_user_id: run.responsibleUserId }, true); const [latest] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( eq(heartbeatRuns.companyId, run.companyId), @@ -1178,7 +1179,7 @@ export function connectionIntentService(db: Db) { const router = aiConnectionRouterBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data; const selected = router ? record(run.contextSnapshot?.aiRouterSelection) : null; const managed = router && selected ? await managedAgent(run.companyId, run.agentId, String(record(selected.binding)?.provider), { sourceRunId: run.id }) : null; - const binding = router ? managed?.binding : saved ?? aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig); + const binding = router ? managed?.binding : saved ?? aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig, run); if (!binding) return null; const attribution = record(run.contextSnapshot?.aiConnection); if (attribution && attribution.provider !== binding.provider) return null; diff --git a/server/src/services/run-liveness.ts b/server/src/services/run-liveness.ts index dc5ca30e365..4b9752f1b02 100644 --- a/server/src/services/run-liveness.ts +++ b/server/src/services/run-liveness.ts @@ -1,4 +1,4 @@ -import { isAiAuthenticationFailure } from "./ai-auth-failure.js"; +import { isAiAuthenticationRepairable } from "./ai-auth-failure.js"; import type { HeartbeatRunStatus, IssueStatus, RunLivenessState } from "@paperclipai/shared"; export type RunLivenessActionability = @@ -323,7 +323,7 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL return output("needs_followup", input.errorCode ? `Run interrupted (${input.errorCode})` : "Run interrupted"); } - if (input.runStatus === "failed" && input.authenticationRepairRequested && isAiAuthenticationFailure(input.errorCode)) { + if (input.runStatus === "failed" && input.authenticationRepairRequested && isAiAuthenticationRepairable(input)) { return output("blocked", "Waiting for provider authentication repair", "Complete the connection request to continue this task"); } diff --git a/tests/e2e/ai-personal-connection-recovery.spec.ts b/tests/e2e/ai-personal-connection-recovery.spec.ts new file mode 100644 index 00000000000..84b3864bcc8 --- /dev/null +++ b/tests/e2e/ai-personal-connection-recovery.spec.ts @@ -0,0 +1,76 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { json } from "./agent-chat.shared"; + +test("missing onboarding key offers personal Claude setup in chat and resumes once", async ({ page, request }) => { + test.setTimeout(120_000); + const root = await mkdtemp(path.join(os.tmpdir(), "personal-ai-recovery-")); + const original = await json(await request.get("/api/instance/settings/experimental")); + const company = await json(await request.post("/api/companies", { data: { name: `Personal AI recovery ${Date.now()}` } })); + try { + await json(await request.patch("/api/instance/settings/experimental", { data: { enableAgentChat: true, enableClassicTaskInterface: false } })); + const definition = await json(await request.post(`/api/companies/${company.id}/user-secret-definitions`, { + data: { key: "ANTHROPIC_API_KEY", name: "ANTHROPIC_API_KEY for onboarding" }, + })); + await writeFile(path.join(root, "continued"), "ready"); + const agent = await json(await request.post(`/api/companies/${company.id}/agents`, { data: { + name: "Chief of Staff", role: "general", adapterType: "claude_local", + adapterConfig: { engine: "acp", cwd: root, stateDir: path.join(root, "state"), + agentCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve("scripts/mcp-fixtures/servers/acp-stop-agent.mjs"))}`, + env: { ANTHROPIC_API_KEY: { type: "user_secret_ref", key: definition.key, version: "latest", required: true }, + PAPERCLIP_STOP_FIXTURE_ROOT: root } }, + runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } }, + } })); + const chatPath = `/api/companies/${company.id}/chats/${agent.id}`; + await page.goto(`/${company.issuePrefix}/chats/${agent.id}`); + await page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]').fill("Say hello."); + await page.getByTestId("task-chat-composer-send").click(); + await expect(page.getByText("Connect your Claude account", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Chief of Staff needs your own AI connection.", { exact: false })).toBeVisible(); + await expect(page.getByRole("button", { name: "Try again", exact: true })).toHaveCount(0); + const chat = await json(await request.get(chatPath)); + // Real pre-dispatch failure, not a seeded card or mocked Paperclip response. + const before = await json(await request.get(`/api/companies/${company.id}/heartbeat-runs`)); + expect(before).toHaveLength(1); + const failed = await json(await request.get(`/api/heartbeat-runs/${before[0].id}`)); + expect(failed).toMatchObject({ status: "failed", errorCode: "configuration_incomplete", responsibleUserId: "local-board", + resultJson: { configurationIncomplete: { reason: "secret_binding_missing", missingBindings: [expect.objectContaining({ errorCode: "user_secret_missing" })] } } }); + const sourceRunId = before[0].id; + await test.info().attach("missing-personal-ai-card", { body: await page.screenshot(), contentType: "image/png" }); + + await page.getByRole("button", { name: "Connect Claude", exact: true }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.getByRole("button", { name: "Use API key instead", exact: true }).click(); + await page.getByPlaceholder("Enter API key here").fill("paperclip-e2e-personal-claude-key"); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.getByRole("button", { name: "Use connection and continue", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Use connection and continue", exact: true }).click(); + await expect(page.getByText("Answered the pending follow-up once.", { exact: false })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("Claude connected", { exact: true })).toBeVisible(); + await expect.poll(async () => { + const after = await json(await request.get(`/api/companies/${company.id}/heartbeat-runs`)); + return after.filter((run: { id: string; status: string }) => run.id !== sourceRunId).map((run: { status: string }) => run.status); + }).toEqual(["succeeded"]); + const updatedAgent = await json(await request.get(`/api/agents/${agent.id}`)); + expect(updatedAgent.runtimeConfig.aiConnection).toMatchObject({ provider: "anthropic", mode: "responsible_user" }); + const { connections: accounts } = await json(await request.get(`/api/companies/${company.id}/ai-connections`)); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ provider: "anthropic", ownership: "personal", ownerUserId: "local-board", status: "connected" }); + const after = await json(await request.get(`/api/companies/${company.id}/heartbeat-runs`)); + const resumed = await json(await request.get(`/api/heartbeat-runs/${after.find((run: { id: string }) => run.id !== sourceRunId).id}`)); + expect(resumed.contextSnapshot.aiConnection).toMatchObject({ connectionId: accounts[0].id, responsibleUserId: "local-board" }); + expect((await json(await request.get(`/api/issues/${chat.id}`))).status).toBe("in_review"); + await page.reload(); + await expect(page.getByText("Answered the pending follow-up once.", { exact: false })).toBeVisible(); + await expect(page.getByRole("button", { name: "Connect Claude", exact: true })).toHaveCount(0); + await test.info().attach("personal-ai-repair-resumed", { body: await page.screenshot(), contentType: "image/png" }); + } finally { + await Promise.allSettled([ + request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } }), + request.patch("/api/instance/settings/experimental", { data: original }), + ]); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/e2e/fixtures/ai-connection-provider.mjs b/tests/e2e/fixtures/ai-connection-provider.mjs new file mode 100644 index 00000000000..af26fe8e2ad --- /dev/null +++ b/tests/e2e/fixtures/ai-connection-provider.mjs @@ -0,0 +1,16 @@ +// Only the upstream credential check is simulated. The vault, card, adoption, +// runtime credential selection, and continuation delivery use real Paperclip APIs. +if (process.env.NODE_ENV === "test") { + const realFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + if (url.hostname === "api.anthropic.com" + && headers.get("x-api-key") === "paperclip-e2e-personal-claude-key") { + return url.pathname === "/v1/models" + ? Response.json({ data: [], has_more: false }) + : Response.json({ error: "Unsupported fixture Anthropic request" }, { status: 422 }); + } + return realFetch(input, init); + }; +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 0d68ae5724b..079c9166df9 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -79,7 +79,7 @@ export default defineConfig({ ...process.env, NODE_ENV: "test", PAPERCLIP_UI_DEV_MIDDLEWARE: "false", - NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${path.resolve(import.meta.dirname, "fixtures/agent-chat-github.mjs")}`, + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${path.resolve(import.meta.dirname, "fixtures/agent-chat-github.mjs")} --import=${path.resolve(import.meta.dirname, "fixtures/ai-connection-provider.mjs")}`, PORT: String(PORT), PAPERCLIP_OPEN_ON_LISTEN: "false", PAPERCLIP_API_URL: BASE_URL, diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 1a111c1167c..fe808fe9ed6 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -16,6 +16,7 @@ import type { } from "@paperclipai/shared"; import { heartbeatsApi } from "@/api/heartbeats"; import { nativeRunEventsToTranscript } from "./transcript/native-run-events"; +import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures"; import type { HeartbeatRunEvent } from "@paperclipai/shared"; const transcriptState = vi.hoisted(() => ({ @@ -1010,6 +1011,22 @@ describe("TaskChatThread runtime transcript selection", () => { expect(container.textContent).not.toContain("Workspace restore failed"); }); + it("directs a missing personal AI credential to its card without offering a premature retry", () => { + render( {}} issueStatus="blocked" + onRetryFailedRun={vi.fn()} interactions={[{ + ...pendingConnectionIntentInteraction, sourceRunId: "missing-ai-run", + payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" }, + }]} linkedRuns={[{ + runId: "missing-ai-run", runtimeMode: "legacy", status: "failed", errorCode: "configuration_incomplete", + agentId: "agent-1", agentName: "Chief of Staff", adapterType: "claude_local", + createdAt: "2026-08-25T18:00:00.000Z", startedAt: null, finishedAt: "2026-08-25T18:00:02.000Z", + }]} />); + expect(container.textContent).toContain("AI connection needed"); + expect(container.textContent).toContain("Use the connection card below to continue."); + expect(container.textContent).not.toContain("The selected AI account is unavailable"); + expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull(); + }); + it("projects the saved Plan inline at its native write_document boundary", () => { planState.data = planDocument({ updatedAt: new Date("2026-08-25T18:00:02.000Z"), diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 45690233df5..c11ae204e86 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -1722,7 +1722,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? workspaceRestoreMarkerDetail({ result: meta?.resultJson, savedPlan, hasResponse: sourceHasPresentationComment || Boolean(acceptedSummary) }) : aiRequest ? aiRequest.status === "pending" - ? "The selected AI account is unavailable. Fix it in the connection card." + ? "Use the connection card below to continue." : "This run stopped because its AI account was unavailable." : source.status === "cancelled" ? code === "execution_reconciliation_required" @@ -1742,15 +1742,16 @@ export function TaskChatThread(props: TaskChatThreadProps) { id, kind: "marker", variant: "interrupted", - label: restoreFailed ? "Workspace restore failed" : source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed", + label: restoreFailed ? "Workspace restore failed" : aiRequest?.status === "pending" ? "AI connection needed" : source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed", runId: source.status === "cancelled" ? undefined : source.id, + ...(aiRequest?.status === "pending" ? { retryable: false } : {}), ...(restoreFailed ? { retryable: meta?.resultJson?.workspaceRestoreFailure !== "restore_unsafe_archive", collapsible: true, runHref: meta?.agentId ? `/agents/${encodeURIComponent(agentMap?.get(meta.agentId)?.urlKey ?? meta.agentId)}/runs/${encodeURIComponent(source.id)}` : undefined, planHref: savedPlan ? "#document-plan" : undefined, } : {}), - tone: source.status === "cancelled" ? "neutral" : "error", + tone: source.status === "cancelled" || aiRequest?.status === "pending" ? "neutral" : "error", detail, }, }); diff --git a/ui/src/components/new-agent/AgentProviderConnection.test.tsx b/ui/src/components/new-agent/AgentProviderConnection.test.tsx index 0c0510428fd..cb77c83b163 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.test.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.test.tsx @@ -228,7 +228,6 @@ describe("AgentProviderConnection reuse", () => { intent, initialMethod: "api_key", fixedMethod: false, onComplete, nameForMethod: method => defaultAiConnectionName("dotta", provider, method), }); - openProvider(); expect(host.querySelector('input[type="password"]')).not.toBeNull(); click("Use subscription instead"); const subscription = mocks.loginPanel.mock.calls.at(-1)![0].aiConnection; diff --git a/ui/src/components/new-agent/AgentProviderConnection.tsx b/ui/src/components/new-agent/AgentProviderConnection.tsx index 71a35f1f766..75aee7460c8 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.tsx @@ -87,7 +87,8 @@ export function AgentProviderConnection({ }; const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(managedAccount?.initialMethod === "api_key" ? "api" : managedAccount ? "subscription" : null); const [advanced, setAdvanced] = useState(false); - const [opened, setOpened] = useState(Boolean(advancedConnection)); + // Connections already selected the provider before showing this step. + const [opened, setOpened] = useState(Boolean(advancedConnection || managedAccount)); const [authorizationUrl, setAuthorizationUrl] = useState(null); const [loginPhase, setLoginPhase] = useState<"preparing" | "ready" | "waiting" | "connecting">("preparing"); const phaseBeforeSubmit = useRef<"ready" | "waiting">("ready"); diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx index efe20b64724..a30d15906cf 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx @@ -509,9 +509,11 @@ describe("AI repair inside the card", () => { if (fails) adoptMock.mockRejectedValue(new Error("Connection test failed")); else adoptMock.mockResolvedValue({ ...interaction, status: "accepted" }); renderBody(interaction); await flush(); - await act(() => button("Fix connection")!.click()); + await act(() => button("Connect OpenAI")!.click()); await act(() => button("Reconnect selected account")!.click()); expect(adoptMock).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Use your OpenAI account"); + expect(document.body.textContent).toContain("Your account is connected."); expect(document.body.textContent).toContain("This replaces the agent’s existing authentication"); await act(() => button("Use connection and continue")!.click()); await flush(); @@ -525,14 +527,17 @@ describe("AI repair inside the card", () => { setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiConnection: { provider, method: "api_key", mode: "responsible_user" } }); completeMock.mockResolvedValue({ ...interaction, status: "accepted" }); renderBody(interaction); await flush(); - await act(() => button("Fix connection")!.click()); + const providerName = provider === "anthropic" ? "Claude" : "OpenAI"; + expect(document.body.textContent).toContain(`Connect your ${providerName} account`); + expect(document.body.textContent).toContain("needs your own AI connection"); + await act(() => button(`Connect ${providerName}`)!.click()); expect(document.querySelector('[role="dialog"]')).toBeNull(); expect(document.querySelector('[data-testid="shared-connection-setup"]')).toBeNull(); expect(document.querySelector('[data-testid="shared-ai-credentials"]')).not.toBeNull(); expect(credentialRender.mock.lastCall![0]).toMatchObject({ provider, hideName: true, ownership: "personal", agentIds: [interaction.payload.requestingAgentId], allAgents: false }); expect(credentialRender.mock.lastCall![0].connectionId).toBeUndefined(); expect(credentialRender.mock.lastCall![0].fixedMethod).toBeUndefined(); - expect(document.body.textContent).toContain("This task can’t run until"); + expect(document.body.textContent).toContain("the task will resume automatically"); await act(() => button("Reconnect selected account")!.click()); expect(completeMock).toHaveBeenCalledWith(interaction.id, "new-ai-account"); }); diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx index 2fe45ef46f6..dafa20679e9 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx @@ -8,7 +8,7 @@ import { RotateCcw, XCircle, } from "lucide-react"; -import type { AiAuthMethod, ConnectionIntentInteraction } from "@paperclipai/shared"; +import { AI_CONNECTION_CAPABILITIES, aiProviderSchema, type AiAuthMethod, type ConnectionIntentInteraction } from "@paperclipai/shared"; import { AgentMailIntentSetup } from "./AgentMailIntentSetup"; import { connectionIntentsApi } from "@/api/connection-intents"; import { aiConnectionsApi } from "@/api/ai-connections"; @@ -64,6 +64,8 @@ export function ConnectionIntentInteractionBody({ ); const isPending = interaction.status === "pending"; const isAi = interaction.payload.purpose === "ai"; + const provider = isAi ? aiProviderSchema.safeParse(interaction.payload.serviceSlug).data : undefined; + const serviceName = provider ? AI_CONNECTION_CAPABILITIES[provider].name : interaction.payload.serviceName; const isEmail = interaction.payload.purpose === "channel" && interaction.payload.serviceSlug === "agentmail"; const accessRequest = interaction.payload.accessRequest; const agentQuery = useQuery({ @@ -241,7 +243,7 @@ export function ConnectionIntentInteractionBody({ interaction.status === "accepted" ? { icon: CheckCircle2, - title: accessRequest ? `${interaction.payload.serviceName} access granted` : interaction.payload.upstreamService ? "External provider connected" : `${interaction.payload.serviceName} connected`, + title: accessRequest ? `${serviceName} access granted` : interaction.payload.upstreamService ? "External provider connected" : `${serviceName} connected`, body: accessRequest ? null : interaction.payload.upstreamService ? `${interaction.payload.requestingAgentName} can now verify and authorize ${interaction.payload.upstreamService.name} through this provider. The app is not yet verified.` : isAi ? "This agent can now use the connection." : `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`, } : interaction.status === "rejected" @@ -378,11 +380,13 @@ export function ConnectionIntentInteractionBody({ renderSetup ? renderSetup(setupProps) : ) : null; const aiConnection = setupQuery.data?.aiConnection; + const needsOwnAiConnection = isAi && (!repair || setupQuery.data?.aiConnectionRequiresAdoption) && aiConnection?.mode === "responsible_user"; + const aiProviderName = aiConnection ? AI_CONNECTION_CAPABILITIES[aiConnection.provider].name : serviceName; const inlineContent = setupQuery.isLoading || setupQuery.isError ? setupContent : readyForAdoption ?

- Use Connections for {interaction.payload.requestingAgentName}? This replaces the agent’s existing authentication - with the responsible person’s {interaction.payload.serviceName} connection. The model stays the same. + Use your {aiProviderName} connection for {interaction.payload.requestingAgentName}? This replaces the agent’s existing authentication + with each responsible person’s own account. The model stays the same.

} {isAi ? :