From 3d15798c22535707cb1a26fb9ab61deecdc0171a Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Thu, 23 Apr 2026 16:45:24 +0200 Subject: [PATCH] fix(adapters/routes): apply resolveExternalAdapterRegistration on hot-install (#4324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The external adapter plugin system (#2218) lets adapters ship as npm modules loaded via `server/src/adapters/plugin-loader.ts`; since #4296 merged, each `ServerAdapterModule` can declare `sessionManagement` (`supportsSessionResume`, `nativeContextManagement`, `defaultSessionCompaction`) and have it preserved through the init-time load via the new `resolveExternalAdapterRegistration` helper > - #4296 fixed the init-time IIFE path at `server/src/adapters/registry.ts:363-369` but noted that the hot-install path at `server/src/routes/adapters.ts:174 registerWithSessionManagement` still unconditionally overwrites module-provided `sessionManagement` during `POST /api/adapters/install` > - Practical impact today: an external adapter installed via the API needs a Paperclip restart before its declared `sessionManagement` takes effect — the IIFE runs on next boot and preserves it, but until then the hot-install overwrite wins > - This PR closes that parity gap: `registerWithSessionManagement` delegates to the same `resolveExternalAdapterRegistration` helper introduced by #4296, unifying both load paths behind one resolver > - The benefit is consistent behaviour between cold-start and hot-install: no "install then restart" ritual; declared `sessionManagement` on an external module is honoured the moment `POST /api/adapters/install` returns 201 ## What Changed - `server/src/routes/adapters.ts`: `registerWithSessionManagement` delegates to the exported `resolveExternalAdapterRegistration` helper (added in #4296). Honours module-provided `sessionManagement` first, falls back to host registry lookup, defaults `undefined`. Updated the section comment to document the parity-with-IIFE intent. - `server/src/routes/adapters.ts`: dropped the now-unused `getAdapterSessionManagement` import. - `server/src/adapters/registry.ts`: updated the JSDoc on `resolveExternalAdapterRegistration` — previously said "Exported for unit tests; runtime callers use the IIFE below", now says the helper is used by both the init-time IIFE and the hot-install path in `routes/adapters.ts`. Addresses Greptile C1. - `server/src/__tests__/adapter-routes.test.ts`: new integration test — installs a mocked external adapter module carrying a non-trivial `sessionManagement` declaration and asserts `findServerAdapter(type).sessionManagement` preserves it after `POST /api/adapters/install` returns 201. - `server/src/__tests__/adapter-routes.test.ts`: added `findServerAdapter` to the shared test-scope variable set so the new test can inspect post-install registry state. ## Verification Targeted test runs from a clean tree on `fix/external-session-management-hot-install` (rebased onto current `upstream/master` now that #4296 has merged): - `pnpm test server/src/__tests__/adapter-routes.test.ts` — 6 passed (new test + 5 pre-existing) - `pnpm test server/src/__tests__/adapter-registry.test.ts` — 15 passed (ensures the IIFE path from #4296 continues to behave correctly) - `pnpm -w run test` full workspace suite — 1923 passed / 1 skipped (unrelated skip) End-to-end smoke on file: [`@superbiche/cline-paperclip-adapter@0.1.1`](https://www.npmjs.com/package/@superbiche/cline-paperclip-adapter) and [`@superbiche/qwen-paperclip-adapter@0.1.1`](https://www.npmjs.com/package/@superbiche/qwen-paperclip-adapter), both public on npm, both declare `sessionManagement`. With this PR in place, the "restart after install" step disappears — the declared compaction policy is active immediately after the install response. ## Risks - Low risk. The change replaces an inline mutation with a call to a helper that already has dedicated unit coverage (#4296 added three tests for `resolveExternalAdapterRegistration` covering module-provided, registry-fallback, and undefined paths). Behaviour is a strict superset of the prior path — externals that did not declare `sessionManagement` continue to get the hardcoded-registry lookup; externals that did declare it now have those values preserved instead of overwritten. - No migration impact. The stored plugin records (`~/.paperclip/adapter-plugins.json`) are unchanged. Existing hot-installed adapters behave correctly before and after. - No behavioural change for builtin adapters; they hit `registerServerAdapter` directly and never flow through `registerWithSessionManagement`. ## Model Used - Provider and model: Claude (Anthropic) via Claude Code - Model ID: `claude-opus-4-7` (1M context) - Reasoning mode: standard (no extended thinking on this PR) - Tool use: yes — file edits, subprocess invocations for builds/tests/git via the Claude Code harness ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (N/A — server-only change) - [x] I have updated relevant documentation to reflect my changes (the JSDoc on `resolveExternalAdapterRegistration` and the section comment above `registerWithSessionManagement` now document the parity-with-IIFE intent) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- server/src/__tests__/adapter-routes.test.ts | 42 +++++++++++++++++++++ server/src/adapters/registry.ts | 6 ++- server/src/routes/adapters.ts | 18 +++++---- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index e1a730d3..eede2af2 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -45,6 +45,7 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = { let registerServerAdapter: typeof import("../adapters/registry.js").registerServerAdapter; let unregisterServerAdapter: typeof import("../adapters/registry.js").unregisterServerAdapter; +let findServerAdapter: typeof import("../adapters/registry.js").findServerAdapter; let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused; let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes; let errorHandler: typeof import("../middleware/index.js").errorHandler; @@ -107,6 +108,7 @@ describe("adapter routes", () => { ]); registerServerAdapter = registry.registerServerAdapter; unregisterServerAdapter = registry.unregisterServerAdapter; + findServerAdapter = registry.findServerAdapter; setOverridePaused = registry.setOverridePaused; adapterRoutes = routes.adapterRoutes; errorHandler = middleware.errorHandler; @@ -236,4 +238,44 @@ describe("adapter routes", () => { expect(res.status, JSON.stringify(res.body)).toBe(403); }); + + it("POST /api/adapters/install preserves module-provided sessionManagement (hot-install parity with init-time IIFE)", async () => { + const HOT_INSTALL_TYPE = "hot_install_session_test"; + const declaredSessionManagement = { + supportsSessionResume: true, + nativeContextManagement: "confirmed" as const, + defaultSessionCompaction: { + enabled: true, + maxSessionRuns: 10, + maxRawInputTokens: 100_000, + maxSessionAgeHours: 24, + }, + }; + const externalModule: ServerAdapterModule = { + type: HOT_INSTALL_TYPE, + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: HOT_INSTALL_TYPE, + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + sessionManagement: declaredSessionManagement, + }; + mockPluginLoader.loadExternalAdapterPackage.mockResolvedValue(externalModule); + + const app = createApp({ isInstanceAdmin: true }); + const res = await request(app) + .post("/api/adapters/install") + .send({ packageName: "/tmp/fake-hot-install-adapter", isLocalPath: true }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body.type).toBe(HOT_INSTALL_TYPE); + + const registered = findServerAdapter(HOT_INSTALL_TYPE); + expect(registered).not.toBeNull(); + expect(registered?.sessionManagement).toEqual(declaredSessionManagement); + + unregisterServerAdapter(HOT_INSTALL_TYPE); + }); }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 40202edc..8268f062 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -353,8 +353,10 @@ function getDisabledAdapterTypesFromStore(): string[] { * override a built-in — same `type` — inherit the builtin's policy). If * neither is available, `sessionManagement` remains `undefined`. * - * Exported for unit tests; runtime callers use the IIFE below, which - * applies this transformation during the external-adapter load pass. + * Used by both the init-time IIFE below (external-adapter load pass on + * server start) and the hot-install path in `routes/adapters.ts` + * (`registerWithSessionManagement`), so the two load paths resolve + * `sessionManagement` identically. */ export function resolveExternalAdapterRegistration( externalAdapter: ServerAdapterModule, diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 40e5800e..efe52b48 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -25,11 +25,11 @@ import { findActiveServerAdapter, listEnabledServerAdapters, registerServerAdapter, + resolveExternalAdapterRegistration, unregisterServerAdapter, isOverridePaused, setOverridePaused, } from "../adapters/registry.js"; -import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { listAdapterPlugins, addAdapterPlugin, @@ -168,15 +168,17 @@ async function normalizeLocalPath(rawPath: string): Promise { } /** - * Register an adapter module into the server registry, filling in - * sessionManagement from the host. + * Register an external adapter module into the server registry via the + * hot-install path, resolving `sessionManagement` identically to how the + * init-time IIFE does. Module-provided `sessionManagement` is honored first, + * with fallback to the host registry by type for builtin-type overrides. + * + * Keeps the hot-install and init-time paths at parity so an adapter installed + * via `POST /api/adapters/install` has the same shape in the registry as the + * same adapter loaded on the next server restart. */ function registerWithSessionManagement(adapter: ServerAdapterModule): void { - const wrapped: ServerAdapterModule = { - ...adapter, - sessionManagement: getAdapterSessionManagement(adapter.type) ?? undefined, - }; - registerServerAdapter(wrapped); + registerServerAdapter(resolveExternalAdapterRegistration(adapter)); } // ---------------------------------------------------------------------------