fix(plugins): snapshot text transforms

This commit is contained in:
Vincent Koc
2026-06-05 14:51:45 +02:00
parent cc7b1f9c2c
commit 1fe17c10cb
2 changed files with 175 additions and 5 deletions

View File

@@ -0,0 +1,96 @@
// Text transform registration tests cover plugin-owned replacement snapshotting.
import {
createPluginRegistryFixture,
registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it } from "vitest";
import { applyPluginTextReplacements } from "../../agents/plugin-text-transforms.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../runtime.js";
import { createPluginRecord } from "../status.test-helpers.js";
import { resolveRuntimeTextTransforms } from "../text-transforms.runtime.js";
import type { PluginTextReplacement, PluginTextTransformRegistration } from "../types.js";
describe("plugin text transform registration", () => {
afterEach(() => {
resetPluginRuntimeStateForTest();
});
it("snapshots replacement fields before runtime transform resolution", () => {
let inputReads = 0;
let outputReads = 0;
let inputFromReads = 0;
let inputToReads = 0;
let outputFromReads = 0;
let outputToReads = 0;
const inputReplacement = {
get from() {
inputFromReads += 1;
if (inputFromReads > 1) {
throw new Error("input from getter re-read");
}
return "red";
},
get to() {
inputToReads += 1;
if (inputToReads > 1) {
throw new Error("input to getter re-read");
}
return "blue";
},
} as PluginTextReplacement;
const outputReplacement = {
get from() {
outputFromReads += 1;
if (outputFromReads > 1) {
throw new Error("output from getter re-read");
}
return /done/u;
},
get to() {
outputToReads += 1;
if (outputToReads > 1) {
throw new Error("output to getter re-read");
}
return "finished";
},
} as PluginTextReplacement;
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "volatile-text-transform",
name: "Volatile Text Transform",
}),
register(api) {
api.registerTextTransforms({
get input() {
inputReads += 1;
if (inputReads > 1) {
throw new Error("text transform input getter re-read");
}
return [inputReplacement];
},
get output() {
outputReads += 1;
if (outputReads > 1) {
throw new Error("text transform output getter re-read");
}
return [outputReplacement];
},
} as PluginTextTransformRegistration);
},
});
setActivePluginRegistry(registry.registry);
const transforms = resolveRuntimeTextTransforms();
expect(applyPluginTextReplacements("red prompt", transforms?.input)).toBe("blue prompt");
expect(applyPluginTextReplacements("all done", transforms?.output)).toBe("all finished");
expect(inputReads).toBe(1);
expect(outputReads).toBe(1);
expect(inputFromReads).toBe(1);
expect(inputToReads).toBe(1);
expect(outputFromReads).toBe(1);
expect(outputToReads).toBe(1);
});
});

View File

@@ -1172,10 +1172,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record: PluginRecord,
transforms: PluginTextTransformsRegistration["transforms"],
) => {
if (
(!transforms.input || transforms.input.length === 0) &&
(!transforms.output || transforms.output.length === 0)
) {
const snapshot = snapshotTextTransforms(record, transforms);
if (!snapshot) {
return;
}
if (!snapshot.input && !snapshot.output) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
@@ -1187,12 +1188,85 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registry.textTransforms.push({
pluginId: record.id,
pluginName: record.name,
transforms,
transforms: snapshot,
source: record.source,
rootDir: record.rootDir,
});
};
const snapshotTextTransforms = (
record: PluginRecord,
transforms: PluginTextTransformsRegistration["transforms"],
): PluginTextTransformsRegistration["transforms"] | undefined => {
let input: unknown;
let output: unknown;
try {
input = transforms.input;
output = transforms.output;
const inputReplacements = snapshotTextReplacementList(record, input, "input");
const outputReplacements = snapshotTextReplacementList(record, output, "output");
if (!inputReplacements.ok || !outputReplacements.ok) {
return undefined;
}
return {
...(inputReplacements.value.length > 0 ? { input: inputReplacements.value } : {}),
...(outputReplacements.value.length > 0 ? { output: outputReplacements.value } : {}),
};
} catch (error) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `text transform registration has unreadable fields: ${formatErrorMessage(error)}`,
});
return undefined;
}
};
const snapshotTextReplacementList = (
record: PluginRecord,
value: unknown,
direction: "input" | "output",
):
| {
ok: true;
value: NonNullable<PluginTextTransformsRegistration["transforms"][typeof direction]>;
}
| { ok: false } => {
if (value === undefined) {
return { ok: true, value: [] };
}
if (!Array.isArray(value)) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `text transform ${direction} replacements must be an array`,
});
return { ok: false };
}
const replacements: NonNullable<
PluginTextTransformsRegistration["transforms"][typeof direction]
> = [];
for (const [index, replacement] of value.entries()) {
try {
replacements.push({
from: replacement.from,
to: replacement.to,
});
} catch (error) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `text transform ${direction} replacement ${index + 1} has unreadable fields: ${formatErrorMessage(error)}`,
});
return { ok: false };
}
}
return { ok: true, value: replacements };
};
const registerEmbeddingProviderForPlugin = (
record: PluginRecord,
adapter: EmbeddingProviderAdapter,