mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
fix(connections): recover missing personal AI credentials in chat (#15376)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ea8e686197
commit
2ca0d26a99
19 files changed
+359
-35
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : 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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Heartbea
|
||||
eq(heartbeatRuns.companyId, issue.companyId),
|
||||
sql`coalesce(${heartbeatRuns.contextSnapshot}->>'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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(<TaskChatThread comments={[]} onAdd={async () => {}} 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"),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [loginPhase, setLoginPhase] = useState<"preparing" | "ready" | "waiting" | "connecting">("preparing");
|
||||
const phaseBeforeSubmit = useRef<"ready" | "waiting">("ready");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) : <ConnectionSetupFlow {...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 ? <div className="space-y-3">
|
||||
<p className="text-sm">
|
||||
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.
|
||||
</p>
|
||||
<Button disabled={adoptMutation.isPending} onClick={() => adoptMutation.mutate(readyForAdoption)}>
|
||||
{adoptMutation.isPending ? "Checking connection…" : "Use connection and continue"}
|
||||
@@ -450,10 +454,14 @@ export function ConnectionIntentInteractionBody({
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{isAi ? `${interaction.payload.serviceName} authentication required` : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`}
|
||||
{readyForAdoption ? `Use your ${aiProviderName} account` : needsOwnAiConnection ? `Connect your ${aiProviderName} account` : isAi ? `${aiProviderName} authentication required` : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{interaction.payload.purpose === "ai"
|
||||
{readyForAdoption
|
||||
? `Your account is connected. Use it for ${interaction.payload.requestingAgentName} to resume this task.`
|
||||
: needsOwnAiConnection
|
||||
? `${interaction.payload.requestingAgentName} needs your own AI connection. Connect here and the task will resume automatically.`
|
||||
: interaction.payload.purpose === "ai"
|
||||
? "This task can’t run until the agent has a valid AI connection. Connect here and the task will resume automatically."
|
||||
: isEmail ? "Connect AgentMail to create an email address for this agent."
|
||||
: "Connect your identity or reuse an eligible connection. Access is added only for this agent."}
|
||||
@@ -491,7 +499,7 @@ export function ConnectionIntentInteractionBody({
|
||||
Not now
|
||||
</Button>}
|
||||
{isAi ? <Button type="button" disabled={completeMutation.isPending || adoptMutation.isPending || selectAiAccountMutation.isPending} onClick={() => open ? closeSetup() : setOpen(true)}>
|
||||
<Plug className="h-4 w-4" />{open ? "Close setup" : "Fix connection"}
|
||||
<Plug className="h-4 w-4" />{open ? "Close setup" : readyForAdoption ? "Continue setup" : needsOwnAiConnection ? `Connect ${aiProviderName}` : "Fix connection"}
|
||||
</Button> : <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button">
|
||||
|
||||
Reference in new issue
Block a user