fix(plugins): snapshot embedding provider registrations

This commit is contained in:
Vincent Koc
2026-06-05 15:30:05 +02:00
parent 8a204f37ef
commit ec1cd7bcec
3 changed files with 247 additions and 12 deletions

View File

@@ -5,7 +5,10 @@ import {
registerVirtualTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { describe, expect, it } from "vitest";
import { getRegisteredEmbeddingProvider } from "../embedding-providers.js";
import {
getRegisteredEmbeddingProvider,
type EmbeddingProviderAdapter,
} from "../embedding-providers.js";
describe("embedding provider registration", () => {
it("keeps public SDK helpers read-only so plugins cannot bypass manifest ownership", () => {
@@ -68,6 +71,74 @@ describe("embedding provider registration", () => {
expect(registry.registry.plugins[0]?.embeddingProviderIds).toContain("embedding-owner");
});
it("snapshots adapter fields before runtime lookup", async () => {
let idReads = 0;
let defaultModelReads = 0;
let createReads = 0;
let formatSetupErrorReads = 0;
const events: string[] = [];
const { config, registry } = createPluginRegistryFixture();
registerVirtualTestPlugin({
registry,
config,
id: "volatile-embedding-owner",
name: "Volatile Embedding Owner",
contracts: {
embeddingProviders: ["volatile-embedding"],
},
register(api) {
api.registerEmbeddingProvider({
marker: "original",
get id() {
idReads += 1;
if (idReads > 1) {
throw new Error("embedding id getter re-read");
}
return " volatile-embedding ";
},
get defaultModel() {
defaultModelReads += 1;
if (defaultModelReads > 1) {
throw new Error("embedding defaultModel getter re-read");
}
return "embedding-model";
},
get create() {
createReads += 1;
if (createReads > 1) {
throw new Error("embedding create getter re-read");
}
return async function (this: { marker?: string }) {
events.push(`create:${this.marker ?? "missing"}`);
return { provider: null };
};
},
get formatSetupError() {
formatSetupErrorReads += 1;
if (formatSetupErrorReads > 1) {
throw new Error("embedding formatSetupError getter re-read");
}
return function (this: { marker?: string }, err: unknown) {
return `${this.marker}:${String(err)}`;
};
},
} as EmbeddingProviderAdapter & { marker: string });
},
});
expect(registry.registry.diagnostics).toEqual([]);
const provider = getRegisteredEmbeddingProvider("volatile-embedding");
expect(provider?.adapter.defaultModel).toBe("embedding-model");
await expect(provider?.adapter.create({} as never)).resolves.toEqual({ provider: null });
expect(provider?.adapter.formatSetupError?.("boom")).toBe("original:boom");
expect(events).toEqual(["create:original"]);
expect(idReads).toBe(1);
expect(defaultModelReads).toBe(1);
expect(createReads).toBe(1);
expect(formatSetupErrorReads).toBe(1);
});
it("rejects duplicate provider ids", () => {
const { config, registry } = createPluginRegistryFixture();

View File

@@ -4,7 +4,10 @@ import {
registerVirtualTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { describe, expect, it } from "vitest";
import { getRegisteredMemoryEmbeddingProvider } from "../memory-embedding-providers.js";
import {
getRegisteredMemoryEmbeddingProvider,
type MemoryEmbeddingProviderAdapter,
} from "../memory-embedding-providers.js";
import { createPluginRecord } from "../status.test-helpers.js";
describe("memory embedding provider registration", () => {
@@ -57,6 +60,109 @@ describe("memory embedding provider registration", () => {
expect(provider?.ownerPluginId).toBe("external-vector");
});
it("skips inactive dual-kind memory adapters before reading adapter fields", () => {
let idReads = 0;
const { config, registry } = createPluginRegistryFixture();
registerVirtualTestPlugin({
registry,
config,
id: "inactive-dual-memory",
name: "Inactive Dual Memory",
kind: ["memory", "context-engine"],
register(api) {
api.registerMemoryEmbeddingProvider({
get id() {
idReads += 1;
throw new Error("inactive memory embedding id getter read");
},
create: async () => ({ provider: null }),
});
},
});
expect(getRegisteredMemoryEmbeddingProvider("inactive-dual-memory")).toBeUndefined();
expect(idReads).toBe(0);
expect(registry.registry.diagnostics).toEqual([
{
pluginId: "inactive-dual-memory",
level: "warn",
source: "/virtual/inactive-dual-memory/index.ts",
message:
"dual-kind plugin not selected for memory slot; skipping memory embedding provider registration",
},
]);
});
it("snapshots adapter fields before memory embedding runtime lookup", async () => {
let idReads = 0;
let defaultModelReads = 0;
let createReads = 0;
let shouldContinueReads = 0;
const events: string[] = [];
const { config, registry } = createPluginRegistryFixture();
registerVirtualTestPlugin({
registry,
config,
id: "volatile-memory-vector",
name: "Volatile Memory Vector",
contracts: {
memoryEmbeddingProviders: ["volatile-memory-vector"],
},
register(api) {
api.registerMemoryEmbeddingProvider({
marker: "original",
get id() {
idReads += 1;
if (idReads > 1) {
throw new Error("memory embedding id getter re-read");
}
return " volatile-memory-vector ";
},
get defaultModel() {
defaultModelReads += 1;
if (defaultModelReads > 1) {
throw new Error("memory embedding defaultModel getter re-read");
}
return "memory-model";
},
get create() {
createReads += 1;
if (createReads > 1) {
throw new Error("memory embedding create getter re-read");
}
return async function (this: { marker?: string }) {
events.push(`create:${this.marker ?? "missing"}`);
return { provider: null };
};
},
get shouldContinueAutoSelection() {
shouldContinueReads += 1;
if (shouldContinueReads > 1) {
throw new Error("memory embedding shouldContinue getter re-read");
}
return function (this: { marker?: string }) {
events.push(`continue:${this.marker ?? "missing"}`);
return true;
};
},
} as MemoryEmbeddingProviderAdapter & { marker: string });
},
});
expect(registry.registry.diagnostics).toEqual([]);
const provider = getRegisteredMemoryEmbeddingProvider("volatile-memory-vector");
expect(provider?.adapter.defaultModel).toBe("memory-model");
await expect(provider?.adapter.create({} as never)).resolves.toEqual({ provider: null });
expect(provider?.adapter.shouldContinueAutoSelection?.(new Error("boom"))).toBe(true);
expect(events).toEqual(["create:original", "continue:original"]);
expect(idReads).toBe(1);
expect(defaultModelReads).toBe(1);
expect(createReads).toBe(1);
expect(shouldContinueReads).toBe(1);
});
it("records the owning memory plugin id for registered adapters", () => {
const { config, registry } = createPluginRegistryFixture();

View File

@@ -1473,7 +1473,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record: PluginRecord,
adapter: EmbeddingProviderAdapter,
) => {
const id = adapter.id.trim();
const id = readProviderLikeId(record, "embedding provider", adapter);
if (id === undefined) {
return;
}
if (!id) {
pushDiagnostic({
level: "error",
@@ -1512,15 +1515,25 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
const snapshot = snapshotProviderLike(
record,
"embedding provider",
adapter,
id,
embeddingProviderAdapterSnapshotFields,
);
if (!snapshot) {
return;
}
if (registryParams.activateGlobalSideEffects !== false) {
registerEmbeddingProvider(adapter, {
registerEmbeddingProvider(snapshot, {
ownerPluginId: record.id,
});
}
registry.embeddingProviders.push({
pluginId: record.id,
pluginName: record.name,
provider: adapter,
provider: snapshot,
source: record.source,
rootDir: record.rootDir,
});
@@ -1833,6 +1846,26 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
"apply",
] as const;
const embeddingProviderAdapterSnapshotFields = [
"defaultModel",
"transport",
"authProviderId",
"create",
"formatSetupError",
] as const;
const memoryEmbeddingProviderAdapterSnapshotFields = [
"defaultModel",
"transport",
"authProviderId",
"autoSelectPriority",
"allowExplicitWhenConfiguredAuto",
"supportsMultimodalEmbeddings",
"create",
"formatSetupError",
"shouldContinueAutoSelection",
] as const;
const registerSpeechProvider = (record: PluginRecord, provider: SpeechProviderPlugin) => {
const registered = registerUniqueProviderLike({
record,
@@ -4333,18 +4366,43 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
} else if (
!(record.contracts?.memoryEmbeddingProviders ?? []).includes(adapter.id)
}
const id = readProviderLikeId(record, "memory embedding provider", adapter);
if (id === undefined) {
return;
}
if (
!hasKind(record.kind, "memory") &&
!(record.contracts?.memoryEmbeddingProviders ?? []).includes(id)
) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: ${adapter.id}`,
message: `plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: ${id}`,
});
return;
}
const existing = getRegisteredMemoryEmbeddingProvider(adapter.id);
if (!id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "memory embedding provider registration missing id",
});
return;
}
const snapshot = snapshotProviderLike(
record,
"memory embedding provider",
adapter,
id,
memoryEmbeddingProviderAdapterSnapshotFields,
);
if (!snapshot) {
return;
}
const existing = getRegisteredMemoryEmbeddingProvider(id);
if (existing) {
const ownerDetail = existing.ownerPluginId
? ` (owner: ${existing.ownerPluginId})`
@@ -4353,17 +4411,17 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
level: "error",
pluginId: record.id,
source: record.source,
message: `memory embedding provider already registered: ${adapter.id}${ownerDetail}`,
message: `memory embedding provider already registered: ${id}${ownerDetail}`,
});
return;
}
registerMemoryEmbeddingProvider(adapter, {
registerMemoryEmbeddingProvider(snapshot, {
ownerPluginId: record.id,
});
registry.memoryEmbeddingProviders.push({
pluginId: record.id,
pluginName: record.name,
provider: adapter,
provider: snapshot,
source: record.source,
rootDir: record.rootDir,
});