Files
PaperClipAI/server/src/__tests__/adapter-registry.test.ts
T
DottaandPaperclip 2ec82c5774 fix(runner): preserve task context when tool connections change (#14963)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents use tools through company-scoped connections and provider
sessions.
> - Resolving a tool connection currently forces a fresh session even
when the provider can load new tools into the existing conversation.
> - A fresh provider conversation can receive too little history to
continue the task.
> - This pull request adds explicit tool-refresh capabilities and uses
them in both runner paths.
> - Fresh attempts receive bounded task history with source IDs and
retrieval instructions.
> - The benefit is that agents can continue the same task after a
connection changes.

## Linked Issues or Issue Description

**What happened?**

A resolved tool connection forced a fresh provider conversation. The new
conversation could lose the original goal and prior answers. Claude also
rejected resume when only the MCP server set changed.

**Expected behavior**

Resume the provider conversation when its harness can refresh tools.
When a fresh session is required, supply enough bounded history to
continue the task. Preserve company, agent, task, workspace, model,
instruction, and skill checks.

**Steps to reproduce**

1. Start a conversation and agree on a task and its constraints.
2. Request and connect a tool needed for the task.
3. Continue the conversation after the connection resolves.
4. Check that the agent remembers the task and can use the new tool.

**Paperclip version or commit**

The bug was reproduced on master at `c46e41e81`. This branch is rebased
on current master.

**Deployment mode**

Self-hosted server. Both legacy adapters and the native runner are
affected.

Related public work: Refs #13282 for task-backed conversations. Refs
#13057 for the broader session-compaction proposal. Refs #14659 for
another report about local CLI session continuity. This change fixes
tool-connection continuation. Provider authentication repairs keep their
existing recovery behavior.

## What Changed

- Expose tool-refresh support in native harness descriptors and legacy
adapter metadata.
- Request tool refresh after connection resolution. Keep provider
authentication repair as a fresh-session wake.
- Reload current tools and credentials while retaining supported Claude,
Codex, Grok, and other provider conversations.
- Allow MCP-only changes during qualified native recovery. Keep all
other compatibility checks.
- Refresh managed-provider and ACPX tool bindings when attaching a new
run.
- Add a fresh-session handoff for both runner paths. Bound database
reads, excerpts, and the final packet to 24,000 bytes.
- Include the original request, recent messages, decisions, plans, prior
answers, and source IDs. Mark omitted content. Apply reset boundaries,
wake cutoffs, quarantine, and secret redaction.
- Add regression tests and document the capabilities and handoff
behavior.

## Verification

- `pnpm -r typecheck` and `pnpm build` passed. Rust formatting passed.
- Final review fixes passed 314 server tests, 333 adapter utility tests,
139 native-session runtime tests, and 12 managed-provider Rust tests.
They verify historical quarantine, raised budgets across attachment, no
history reads on successful resume, and handoff delivery on fresh retry.
- Broader branch verification also passed 1,401 adapter utility tests,
1,047 runner TypeScript tests, 43 Grok adapter tests, and 311 Rust core
tests.
- Live Claude CLI and Grok ACP probes preserved the provider session ID,
recalled a prior task constraint, and called a newly added read-only MCP
tool.
- GitHub CI passed on `b21486d18084a7aa4cafbe8e012f7cad6585d9cc`: 55
successful checks and 4 skipped checks. This includes all test shards,
all eight browser shards, runner checks, and the Grok clean public npm
install canary. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/37057514976).
- A full local test attempt encountered a separate Git snapshot timeout.
All affected local suites passed after the final edits, and the full CI
test gates passed.
- Review the capability matrix in `packages/paperclip-runner/README.md`.
Repeat the four reproduction steps with a supported provider and with an
unsupported harness.

## Risks

- Provider tool refresh can fail. Existing recovery falls back to a
fresh conversation where policy permits it.
- A new transport can replace an old process while preserving the
provider conversation. Tests cover current credentials and unchanged
identity.
- Long history can omit older context. Explicit markers and source IDs
let the agent retrieve needed context within task scope.
- Unknown and unqualified harnesses use the fresh-session path. No
database migration is required.

## Model Used

OpenAI Codex, GPT-6, with reasoning, tool use, code execution, and live
provider testing. The exact model ID and context-window size are not
exposed in this session. Claude and Grok also ran as test subjects.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-02 15:11:44 -05:00

519 lines
19 KiB
TypeScript

import { probeAcpxClaudeInstallation } from "@paperclipai/paperclip-runner/live";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
import type { ServerAdapterModule } from "../adapters/index.js";
import {
detectAdapterModel,
findActiveServerAdapter,
findServerAdapter,
listAdapterModels,
registerServerAdapter,
requireServerAdapter,
unregisterServerAdapter,
} from "../adapters/index.js";
import {
resolveExternalAdapterRegistration,
setOverridePaused,
} from "../adapters/registry.js";
vi.mock("@paperclipai/paperclip-runner/live", () => ({
probeAcpxClaudeInstallation: vi.fn(async () => undefined),
probeAcpxGrokInstallation: vi.fn(async () => undefined),
}));
it("advertises tool-refresh recovery for the selected legacy harness", () => {
for (const type of ["claude_local", "codex_local", "grok_local", "gemini_local", "kimi_local", "cursor", "opencode_local", "pi_local"]) {
expect(requireServerAdapter(type).supportsToolRefreshOnResume).toBe(true);
expect(requireServerAdapter(type).sessionManagement?.supportsSessionResume).toBe(true);
}
expect(requireServerAdapter("process").supportsToolRefreshOnResume).toBeUndefined();
});
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
}),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
models: [{ id: "external-model", label: "External Model" }],
supportsLocalAgentJwt: false,
};
describe("server adapter registry", () => {
beforeEach(() => {
unregisterServerAdapter("external_test");
unregisterServerAdapter("hermes_local");
unregisterServerAdapter("hermes_gateway");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
});
afterEach(() => {
unregisterServerAdapter("external_test");
unregisterServerAdapter("hermes_local");
unregisterServerAdapter("hermes_gateway");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
});
it("registers external adapters and exposes them through lookup helpers", async () => {
expect(findServerAdapter("external_test")).toBeNull();
registerServerAdapter(externalAdapter);
expect(requireServerAdapter("external_test")).toBe(externalAdapter);
expect(await listAdapterModels("external_test")).toEqual([
{ id: "external-model", label: "External Model" },
]);
});
it("removes external adapters when unregistered", () => {
registerServerAdapter(externalAdapter);
unregisterServerAdapter("external_test");
expect(findServerAdapter("external_test")).toBeNull();
expect(() => requireServerAdapter("external_test")).toThrow(
"Unknown adapter type: external_test",
);
});
it("allows external plugin to override a built-in adapter type", () => {
// claude_local is always built-in
const builtIn = findServerAdapter("claude_local");
expect(builtIn).not.toBeNull();
const plugin: ServerAdapterModule = {
type: "claude_local",
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
}),
testEnvironment: async () => ({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
models: [{ id: "plugin-model", label: "Plugin Override" }],
supportsLocalAgentJwt: false,
};
registerServerAdapter(plugin);
// Plugin wins
const resolved = requireServerAdapter("claude_local");
expect(resolved).toBe(plugin);
expect(resolved.models).toEqual([
{ id: "plugin-model", label: "Plugin Override" },
]);
});
it("ships Hermes adapters as built-ins and still accepts external overrides", () => {
const builtInLocal = findServerAdapter("hermes_local");
const builtInGateway = findServerAdapter("hermes_gateway");
expect(builtInLocal).not.toBeNull();
expect(builtInLocal?.supportsLocalAgentJwt).toBe(true);
expect(builtInLocal?.supportsInstructionsBundle).toBe(true);
expect(builtInLocal?.requiresMaterializedRuntimeSkills).toBe(false);
expect(builtInLocal?.detectModel).toBeTypeOf("function");
expect(builtInLocal?.getConfigSchema).toBeTypeOf("function");
expect(builtInGateway).not.toBeNull();
expect(builtInGateway?.supportsLocalAgentJwt).toBe(false);
expect(builtInGateway?.supportsInstructionsBundle).toBe(false);
expect(builtInGateway?.requiresMaterializedRuntimeSkills).toBe(false);
expect(builtInGateway?.getConfigSchema).toBeTypeOf("function");
const hermesLocalExternalAdapter: ServerAdapterModule = {
type: "hermes_local",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "hermes_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
listSkills: async () => ({
adapterType: "hermes_local",
supported: true,
mode: "ephemeral",
desiredSkills: [],
entries: [],
warnings: [],
}),
getConfigSchema: () => ({ fields: [{ key: "provider", label: "Provider", type: "text" }] }),
detectModel: async () => ({
model: "hermes-model",
provider: "openrouter",
source: "test",
}),
};
const hermesGatewayExternalAdapter: ServerAdapterModule = {
type: "hermes_gateway",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "hermes_gateway",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
supportsLocalAgentJwt: false,
supportsInstructionsBundle: false,
requiresMaterializedRuntimeSkills: false,
getConfigSchema: () => ({
fields: [{ key: "apiBaseUrl", label: "API URL", type: "text" }],
}),
};
registerServerAdapter(hermesLocalExternalAdapter);
expect(requireServerAdapter("hermes_local")).toBe(hermesLocalExternalAdapter);
expect(findActiveServerAdapter("hermes_local")?.supportsLocalAgentJwt).toBe(true);
unregisterServerAdapter("hermes_local");
expect(requireServerAdapter("hermes_local")).toBe(builtInLocal);
registerServerAdapter(hermesGatewayExternalAdapter);
expect(requireServerAdapter("hermes_gateway")).toBe(hermesGatewayExternalAdapter);
expect(findActiveServerAdapter("hermes_gateway")?.supportsLocalAgentJwt).toBe(false);
unregisterServerAdapter("hermes_gateway");
expect(requireServerAdapter("hermes_gateway")).toBe(builtInGateway);
});
it("exposes capability flags from registered adapters", () => {
const adapterWithCaps: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass" as const,
checks: [],
testedAt: new Date(0).toISOString(),
}),
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "customPathKey",
requiresMaterializedRuntimeSkills: true,
};
registerServerAdapter(adapterWithCaps);
const resolved = findActiveServerAdapter("external_test");
expect(resolved).not.toBeNull();
expect(resolved!.supportsInstructionsBundle).toBe(true);
expect(resolved!.instructionsPathKey).toBe("customPathKey");
expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true);
expect(resolved!.supportsLocalAgentJwt).toBe(true);
});
it("returns undefined for capability flags on adapters that do not set them", () => {
registerServerAdapter(externalAdapter);
const resolved = findActiveServerAdapter("external_test");
expect(resolved).not.toBeNull();
expect(resolved!.supportsInstructionsBundle).toBeUndefined();
expect(resolved!.instructionsPathKey).toBeUndefined();
expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined();
});
it("built-in claude_local adapter declares capability flags", () => {
const adapter = findActiveServerAdapter("claude_local");
expect(adapter).not.toBeNull();
expect(adapter!.supportsInstructionsBundle).toBe(true);
expect(adapter!.instructionsPathKey).toBe("instructionsFilePath");
expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false);
expect(adapter!.supportsLocalAgentJwt).toBe(true);
});
it("rejects an incomplete managed runner provider before probing Codex", async () => {
const adapter = requireServerAdapter("paperclip_runner");
expect(adapter.supportsInstructionsBundle).toBe(true);
expect(adapter.instructionsPathKey).toBe("instructionsFilePath");
const result = await adapter.testEnvironment({
companyId: "company-1",
adapterType: "paperclip_runner",
config: { provider: "claude_managed" },
});
expect(result).toMatchObject({
adapterType: "paperclip_runner",
status: "fail",
checks: [{
code: "paperclip_runner_claude_managed_profile_required",
level: "error",
}],
});
});
it.each([
["claude_managed", {
managedProfileId: "managed-primary",
managedAgentsRetentionAcknowledged: true,
}, "claude_managed_profile_selected"],
["aws_agentcore", {
agentCoreProfileId: "agentcore-primary",
agentCoreRetentionAcknowledged: true,
}, "aws_agentcore_profile_selected"],
] as const)("accepts a complete %s profile selection", async (provider, config, code) => {
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
companyId: "company-1",
adapterType: "paperclip_runner",
config: { provider, ...config },
});
expect(result).toMatchObject({
adapterType: "paperclip_runner",
status: "warn",
checks: expect.arrayContaining([expect.objectContaining({ code, level: "info" })]),
});
});
it.each([
["claude", "claude-sonnet-5"],
] as const)("does not claim runtime readiness from the remote ACPX %s platform alone", async (acpxAgent, model) => {
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
companyId: "company-1",
adapterType: "paperclip_runner",
config: { provider: "acpx", acpxAgent, model },
executionTarget: {
kind: "remote",
transport: "sandbox",
remoteCwd: "/workspace",
providerKey: "test-provider",
runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "Linux\nx86_64\n" }) },
},
});
expect(result).toMatchObject({
adapterType: "paperclip_runner",
status: "warn",
checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" }],
});
});
it.each([true, false])("checks actual local ACPX installation readiness (%s)", async (ready) => {
const probe = vi.mocked(probeAcpxClaudeInstallation);
if (ready) probe.mockResolvedValueOnce(undefined);
else probe.mockRejectedValueOnce(new Error("Runtime package integrity verification failed"));
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
companyId: "company-1", adapterType: "paperclip_runner",
config: { provider: "acpx", acpxAgent: "claude", model: "custom-claude-model" },
});
expect(probe).toHaveBeenLastCalledWith("custom-claude-model");
expect(result).toMatchObject({
status: ready ? "pass" : "fail",
checks: [expect.objectContaining({ code: ready ? "acpx_runtime_ready" : "acpx_runtime_unavailable" })],
});
});
it("keeps the ACPX Pi profile unavailable", async () => {
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
companyId: "company-1",
adapterType: "paperclip_runner",
config: {
provider: "acpx",
acpxAgent: "pi",
model: "openrouter/deepseek/deepseek-v4-flash-0731",
},
});
expect(result).toMatchObject({
status: "fail",
checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }],
});
});
it("reports qualification-only readiness for an exact host-authorized candidate", async () => {
const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION";
const previous = process.env[key];
process.env[key] = JSON.stringify([{ agent: "cursor", model: "exact-model" }]);
try {
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
companyId: "company-1", adapterType: "paperclip_runner",
config: { provider: "acpx", acpxAgent: "cursor", model: "exact-model" },
});
expect(result).toMatchObject({ status: "warn", checks: [{ code: "acpx_candidate_qualification_only" }] });
} finally {
if (previous === undefined) delete process.env[key];
else process.env[key] = previous;
}
});
it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => {
const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`;
const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`;
const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`;
const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`;
const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.156.0")}; fi`;
const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.32")}; fi`;
expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "claude",
detectCommand: "claude",
installCommand: expectedClaudeInstall,
});
expect(findActiveServerAdapter("codex_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "codex",
detectCommand: "codex",
installCommand: expectedCodexInstall,
});
expect(findActiveServerAdapter("gemini_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "gemini",
detectCommand: "gemini",
installCommand: expectedGeminiInstall,
});
expect(findActiveServerAdapter("opencode_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "opencode",
detectCommand: "opencode",
installCommand: expectedOpenCodeInstall,
});
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "codex" })).toEqual({
command: "codex",
detectCommand: "codex",
installCommand: expectedRunnerCodexInstall,
});
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "opencode" })).toEqual({
command: "opencode",
detectCommand: "opencode",
installCommand: expectedRunnerOpenCodeInstall,
});
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "acpx" })).toEqual({
command: "paperclip-runnerd",
detectCommand: null,
installCommand: null,
});
});
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
const builtIn = findServerAdapter("claude_local");
expect(builtIn).not.toBeNull();
const detectModel = vi.fn(async () => ({
model: "plugin-model",
provider: "plugin-provider",
source: "plugin-source",
}));
const plugin: ServerAdapterModule = {
type: "claude_local",
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
}),
testEnvironment: async () => ({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
models: [{ id: "plugin-model", label: "Plugin Override" }],
detectModel,
supportsLocalAgentJwt: false,
};
registerServerAdapter(plugin);
expect(findActiveServerAdapter("claude_local")).toBe(plugin);
expect(await listAdapterModels("claude_local")).toEqual([
{ id: "plugin-model", label: "Plugin Override" },
]);
expect(await detectAdapterModel("claude_local")).toMatchObject({
model: "plugin-model",
provider: "plugin-provider",
});
expect(setOverridePaused("claude_local", true)).toBe(true);
expect(findActiveServerAdapter("claude_local")).not.toBe(plugin);
expect(await listAdapterModels("claude_local")).toEqual(builtIn?.models ?? []);
expect(await detectAdapterModel("claude_local")).toBeNull();
expect(detectModel).toHaveBeenCalledTimes(1);
});
});
describe("resolveExternalAdapterRegistration", () => {
it("preserves module-provided sessionManagement", () => {
const sessionManagement = {
supportsSessionResume: true,
nativeContextManagement: "unknown" as const,
defaultSessionCompaction: {
enabled: true,
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
},
};
const adapter: ServerAdapterModule = {
type: "external_session_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "external_session_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
sessionManagement,
};
const resolved = resolveExternalAdapterRegistration(adapter);
expect(resolved.sessionManagement).toBe(sessionManagement);
});
it("falls back to the hardcoded registry when the module omits sessionManagement", () => {
// An external that overrides a built-in type should inherit the built-in's
// sessionManagement when it does not provide its own.
const adapter: ServerAdapterModule = {
type: "claude_local",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
};
const resolved = resolveExternalAdapterRegistration(adapter);
expect(resolved.sessionManagement).toBeDefined();
expect(resolved.sessionManagement?.supportsSessionResume).toBe(true);
expect(resolved.sessionManagement?.nativeContextManagement).toBe("confirmed");
});
it("leaves sessionManagement undefined when neither module nor registry provides one", () => {
const adapter: ServerAdapterModule = {
type: "external_unknown_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "external_unknown_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
};
const resolved = resolveExternalAdapterRegistration(adapter);
expect(resolved.sessionManagement).toBeUndefined();
});
});