fix(adapters/routes): apply resolveExternalAdapterRegistration on hot-install (#4324)

## 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
This commit is contained in:
Michel Tomas
2026-04-23 16:45:24 +02:00
committed by GitHub
parent 24232078fd
commit 3d15798c22
3 changed files with 56 additions and 10 deletions

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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<string> {
}
/**
* 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));
}
// ---------------------------------------------------------------------------