fix(plugins): snapshot session extension registrations

This commit is contained in:
Vincent Koc
2026-06-05 13:50:45 +02:00
parent 92953f6fcf
commit 242811248b
2 changed files with 207 additions and 9 deletions

View File

@@ -0,0 +1,122 @@
// Session extension registration tests cover plugin-owned metadata snapshotting.
import {
createPluginRegistryFixture,
registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it } from "vitest";
import { projectPluginSessionExtensionsSync } from "../host-hook-state.js";
import type { PluginJsonValue, PluginSessionExtensionRegistration } from "../host-hooks.js";
import { createEmptyPluginRegistry } from "../registry-empty.js";
import { setActivePluginRegistry } from "../runtime.js";
import { createPluginRecord } from "../status.test-helpers.js";
function diagnosticSummaries(diagnostics: readonly unknown[]) {
return diagnostics.map((entry) => {
const diagnostic = entry as { pluginId?: string; message?: string };
return { pluginId: diagnostic.pluginId, message: diagnostic.message };
});
}
describe("plugin session extension registration", () => {
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("snapshots extension metadata before session projection", () => {
let projectReads = 0;
let slotSchemaReads = 0;
const slotSchema = { type: "object", properties: { state: { type: "string" } } };
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "volatile-session-extension",
name: "Volatile Session Extension",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "Workflow state",
sessionEntrySlotKey: "approvalSnapshot",
get sessionEntrySlotSchema() {
slotSchemaReads += 1;
if (slotSchemaReads > 1) {
throw new Error("slot schema getter re-read");
}
return slotSchema;
},
get project() {
projectReads += 1;
if (projectReads > 1) {
throw new Error("project getter re-read");
}
return ({ state }) => {
if (!state || typeof state !== "object" || Array.isArray(state)) {
return undefined;
}
return { state: (state as Record<string, PluginJsonValue>).state ?? null };
};
},
} as PluginSessionExtensionRegistration);
},
});
setActivePluginRegistry(registry.registry);
expect(registry.registry.sessionExtensions?.[0]?.extension.sessionEntrySlotSchema).toEqual(
slotSchema,
);
expect(projectReads).toBe(1);
expect(slotSchemaReads).toBe(1);
expect(
projectPluginSessionExtensionsSync({
sessionKey: "agent:main:main",
entry: {
sessionId: "session-1",
updatedAt: 1,
pluginExtensions: {
"volatile-session-extension": {
workflow: { state: "waiting" },
},
},
},
}),
).toEqual([
{
pluginId: "volatile-session-extension",
namespace: "workflow",
value: { state: "waiting" },
},
]);
expect(projectReads).toBe(1);
expect(slotSchemaReads).toBe(1);
});
it("rejects non-JSON session extension slot schemas", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "bad-session-extension-schema",
name: "Bad Session Extension Schema",
}),
register(api) {
api.registerSessionExtension({
namespace: "workflow",
description: "Workflow state",
sessionEntrySlotKey: "approvalSnapshot",
sessionEntrySlotSchema: new Date(0) as never,
});
},
});
expect(registry.registry.sessionExtensions ?? []).toHaveLength(0);
expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
{
pluginId: "bad-session-extension-schema",
message: "session extension slot schema must be JSON-compatible: workflow",
},
]);
});
});

View File

@@ -2030,31 +2030,93 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
};
const readSessionExtensionRegistrationFields = (
record: PluginRecord,
extension: PluginSessionExtensionRegistration,
):
| {
namespace: unknown;
description: unknown;
project: unknown;
cleanup: unknown;
sessionEntrySlotKey: unknown;
sessionEntrySlotSchema: unknown;
}
| undefined => {
let namespace: unknown;
try {
namespace = extension.namespace;
return {
namespace,
description: extension.description,
project: extension.project,
cleanup: extension.cleanup,
sessionEntrySlotKey: extension.sessionEntrySlotKey,
sessionEntrySlotSchema: extension.sessionEntrySlotSchema,
};
} catch (error) {
const normalizedNamespace = normalizeOptionalHostHookString(namespace);
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message:
`session extension registration has unreadable fields` +
`${normalizedNamespace ? `: ${normalizedNamespace}` : ""}: ${formatErrorMessage(error)}`,
});
return undefined;
}
};
const registerSessionExtension = (
record: PluginRecord,
extension: PluginSessionExtensionRegistration,
) => {
const namespace = normalizeHostHookString(extension.namespace);
const description = normalizeHostHookString(extension.description);
const project = extension.project;
const fields = readSessionExtensionRegistrationFields(record, extension);
if (!fields) {
return;
}
const namespace = normalizeHostHookString(fields.namespace);
const description = normalizeHostHookString(fields.description);
const project = fields.project;
const cleanup = fields.cleanup;
let normalizedSessionEntrySlotKey: string | undefined;
let invalidMessage: string | undefined;
if (!namespace || !description) {
invalidMessage = "session extension registration requires namespace and description";
} else if (project !== undefined && typeof project !== "function") {
invalidMessage = "session extension projector must be a function";
} else if (project?.constructor?.name === "AsyncFunction") {
invalidMessage = "session extension projector must be synchronous";
} else if (extension.cleanup !== undefined && typeof extension.cleanup !== "function") {
} else if (project !== undefined) {
try {
if (project.constructor?.name === "AsyncFunction") {
invalidMessage = "session extension projector must be synchronous";
}
} catch (error) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `session extension projector metadata is unreadable: ${namespace}: ${formatErrorMessage(error)}`,
});
return;
}
}
if (!invalidMessage && cleanup !== undefined && typeof cleanup !== "function") {
invalidMessage = "session extension cleanup must be a function";
} else if (extension.sessionEntrySlotKey !== undefined) {
const slotKey = normalizeSessionEntrySlotKey(extension.sessionEntrySlotKey);
}
if (!invalidMessage && fields.sessionEntrySlotKey !== undefined) {
const slotKey = normalizeSessionEntrySlotKey(fields.sessionEntrySlotKey);
if (!slotKey.ok) {
invalidMessage = slotKey.error;
} else {
normalizedSessionEntrySlotKey = slotKey.key;
}
}
if (!invalidMessage && fields.sessionEntrySlotSchema !== undefined) {
if (!isPluginJsonValue(fields.sessionEntrySlotSchema)) {
invalidMessage = `session extension slot schema must be JSON-compatible: ${namespace}`;
}
}
if (invalidMessage) {
pushDiagnostic({
level: "error",
@@ -2101,13 +2163,27 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
(registry.sessionExtensions ??= []).push({
pluginId: record.id,
pluginName: record.name,
// Session extensions are projected into Gateway session rows. Store a
// normalized snapshot so plugin-owned accessors cannot run during reads.
extension: {
...extension,
namespace,
description,
...(project !== undefined
? { project: project as PluginSessionExtensionRegistration["project"] }
: {}),
...(cleanup !== undefined
? { cleanup: cleanup as PluginSessionExtensionRegistration["cleanup"] }
: {}),
...(normalizedSessionEntrySlotKey
? { sessionEntrySlotKey: normalizedSessionEntrySlotKey }
: {}),
...(fields.sessionEntrySlotSchema !== undefined
? {
sessionEntrySlotSchema: fields.sessionEntrySlotSchema as NonNullable<
PluginSessionExtensionRegistration["sessionEntrySlotSchema"]
>,
}
: {}),
},
source: record.source,
rootDir: record.rootDir,