From b4bb78eb70a010b3698d7d57e8dc796cf582b360 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 2 Oct 2026 17:16:41 -0500 Subject: [PATCH] fix(ai): retain account modes in session adoption checks Exercise responsible-user to delegated adoption with real prepared account configurations and preserve all other fingerprint boundaries. Co-Authored-By: Paperclip --- .../__tests__/ai-connection-router.test.ts | 24 ++++++++++++++++++- .../__tests__/ai-connection-session.test.ts | 2 +- server/src/services/ai-connection-session.ts | 15 +++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/ai-connection-router.test.ts b/server/src/__tests__/ai-connection-router.test.ts index 0ce44befd19..20b9cb1c186 100644 --- a/server/src/__tests__/ai-connection-router.test.ts +++ b/server/src/__tests__/ai-connection-router.test.ts @@ -8,6 +8,7 @@ import { createDb, companies, agents, companyMemberships, connectionGrants, plug import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres"; import type { AiConnectionPoolMember, AiConnectionRouterRequest, AiConnectionRouterResult, PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { aiConnectionRouterService, AiConnectionPoolExhausted, poolMemberRuntimeConfig, applyAiConnectionRouterTaskSettings } from "../services/ai-connection-router.js"; +import { aiConnectionSessionCompatibilityInputs } from "../services/ai-connection-session.js"; import { projectPaperclipRunnerTaskConfig, resolvePaperclipRunnerNativeProviderInput } from "../services/native-runtime/provider-profile.js"; import { aiConnectionService } from "../services/ai-connections.js"; import { instanceSettingsService } from "../services/instance-settings.js"; @@ -18,7 +19,7 @@ import express from "express"; import request from "supertest"; import { aiConnectionRoutes } from "../routes/ai-connections.js"; import { errorHandler } from "../middleware/index.js"; -import { heartbeatService } from "../services/heartbeat.js"; +import { heartbeatService, buildEffectiveRunSessionConfigMetadata, resolveTaskSessionConfigFreshness } from "../services/heartbeat.js"; import { connectionIntentService } from "../services/connection-intents.js"; import { legacyExecutionNeedsReconciliation } from "../services/legacy-execution-recovery.js"; import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; @@ -275,6 +276,27 @@ describe("durable, authorized connection routing", () => { try { await expect(resolve(pool.id, "new")).rejects.toThrow("plugin"); expect(await resolve(pool.id, "recovery", { persisted: selected })).toEqual(selected); await db.delete(toolConnections).where(eq(toolConnections.id, pool.id)); expect(await resolve(pool.id, "recovery", { persisted: selected })).toEqual(selected); } finally { await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId)); } }); + it("resumes a responsible-user session through a delegated pool using real managed account configurations", async () => { + await aiConnectionService(db).setDefault(companyId, "alice", members[0]!.binding.grantId!); + const pool = await makePool({ members: [members[0]!] }); + const selected = await resolve(pool.id, "managed-mode-adoption", { requireExisting: true, existingGrantId: members[0]!.binding.grantId }); + const directBinding = { mode: "responsible_user" as const, provider: "openai" as const, method: "api_key" as const }; + const prepare = { companyId, agentId, responsibleUserId: "alice", adapterType: "paperclip_runner", config: selected.runtimeConfig }; + const before = await prepareManagedAiRuntime(db, { ...prepare, binding: directBinding }); + const after = await prepareManagedAiRuntime(db, { ...prepare, binding: selected.binding }); + try { + expect(before.config.managedAiConnection.mode).toBe("responsible_user"); + expect(after.config.managedAiConnection.mode).toBe("delegated"); + expect(after.sessionIdentity).toBe(before.sessionIdentity); + const common = { adapterType: "paperclip_runner", issueOverrides: null, workspaceConfig: {}, environment: {}, environmentEnv: null, projectEnv: null, routineEnv: null, runtimeSkills: [], agentConfigRevision: null }; + const previous = await buildEffectiveRunSessionConfigMetadata({ ...common, effectiveAdapterConfig: before.config, managedAiHome: before.home, agentRuntimeConfig: { aiConnection: directBinding } }); + const currentInput = { ...common, effectiveAdapterConfig: after.config, managedAiHome: after.home, agentRuntimeConfig: { aiConnection: { mode: "router", connectionId: pool.id } } }; + const current = await buildEffectiveRunSessionConfigMetadata(currentInput); + const alternatives = aiConnectionSessionCompatibilityInputs({ ...currentInput, originalIssueOverrides: null, binding: selected.binding, router: true, storedIdentity: before.sessionIdentity, sessionIdentity: after.sessionIdentity, credentialIdentity: after.identity, revisions: [] }); + const compatibleConfigMetadata = await Promise.all(alternatives.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...currentInput, ...candidate }))); + expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: String(selected.runtimeConfig.model), taskSessionParams: { __paperclipConfiguredModel: selected.runtimeConfig.model, __paperclipConfigFingerprint: previous.fingerprint, __paperclipConfigFingerprintVersion: previous.version, __paperclipConfigCategoryFingerprints: previous.categoryFingerprints }, configMetadata: current, compatibleConfigMetadata }).reset).toBe(false); + } finally { await before.cleanup(); await after.cleanup(); } + }); it("keeps session identity and fingerprints stable on authenticated refresh, but changes them on manual replacement", async () => { const input = { companyId, agentId, responsibleUserId: "alice", adapterType: "paperclip_runner", binding: members[0]!.binding, config: { provider: "codex", model: "gpt-5.6-sol" } }; const first = await prepareManagedAiRuntime(db, input); diff --git a/server/src/__tests__/ai-connection-session.test.ts b/server/src/__tests__/ai-connection-session.test.ts index 9fee2355fce..81bed1e2c4c 100644 --- a/server/src/__tests__/ai-connection-session.test.ts +++ b/server/src/__tests__/ai-connection-session.test.ts @@ -26,7 +26,7 @@ async function decision(previous: Partial, next: Partial { it.each([binding, { ...binding, mode: "delegated" }, { mode: "responsible_user", provider: "openai", method: "api_key" }])("preserves the exact existing configuration through a compatible binding change: %j", async aiConnection => { - expect((await decision({ agentRuntimeConfig: { aiConnection } })).reset).toBe(false); + expect((await decision({ agentRuntimeConfig: { aiConnection }, effectiveAdapterConfig: { ...config, managedAiConnection: { ...config.managedAiConnection, mode: aiConnection.mode } } })).reset).toBe(false); }); it("bridges only account-binding edits in revision history", async () => { const first = revision("revision-1", null, binding); diff --git a/server/src/services/ai-connection-session.ts b/server/src/services/ai-connection-session.ts index f0ff8248d39..1b0c4fc5836 100644 --- a/server/src/services/ai-connection-session.ts +++ b/server/src/services/ai-connection-session.ts @@ -86,5 +86,18 @@ export function aiConnectionSessionCompatibilityInputs(input: { } const overrides = input.router && !isDeepStrictEqual(input.issueOverrides, input.originalIssueOverrides) ? [input.issueOverrides, input.originalIssueOverrides] : [input.issueOverrides]; - return variants.flatMap(variant => configs.flatMap(effectiveAdapterConfig => overrides.map(issueOverrides => ({ ...variant, effectiveAdapterConfig, issueOverrides })))); + return variants.flatMap(variant => { + const previousMode = record(record(variant.agentRuntimeConfig).aiConnection).mode; + const modes = input.router + ? ["responsible_user", "shared", "delegated"].includes(String(previousMode)) + ? [previousMode] : ["responsible_user", "shared", "delegated"] + : [record(input.effectiveAdapterConfig.managedAiConnection).mode]; + return configs.flatMap(config => modes.flatMap(mode => overrides.map(issueOverrides => ({ + ...variant, + // Binding mode is also recorded in the managed account attribution. + // Keep grant, user, credential epoch, and every other setting unchanged. + effectiveAdapterConfig: { ...config, managedAiConnection: { ...record(config.managedAiConnection), mode } }, + issueOverrides, + })))); + }); }