mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(plugins): snapshot session extension registrations
This commit is contained in:
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user