fix(context-engine): preserve legacy plugin sessionKey interop (#44779)

Merged via squash.

Prepared head SHA: e04c6fb47d
Co-authored-by: hhhhao28 <112874572+hhhhao28@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
F_ool 2026-03-18 00:14:14 +08:00 committed by GitHub
parent ebee4e2210
commit 094a0cc412
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 396 additions and 1 deletions

View File

@ -302,6 +302,7 @@ Docs: https://docs.openclaw.ai
- Agents/Ollama overflow: rewrite Ollama `prompt too long` API payloads through the normal context-overflow sanitizer so embedded sessions keep the friendly overflow copy and auto-compaction trigger. (#34019) thanks @lishuaigit.
- Control UI/auth: restore one-time legacy `?token=` imports for shared Control UI links while keeping `#token=` preferred, and carry pending query tokens through gateway URL confirmation so compatibility links still authenticate after confirmation. (#43979) Thanks @stim64045-spec.
- Plugins/context engines: retry legacy lifecycle calls once without `sessionKey` when older plugins reject that field, memoize legacy mode after the first strict-schema fallback, and preserve non-compat runtime errors without retry. (#44779) thanks @hhhhao28.
## 2026.3.11

View File

@ -109,6 +109,113 @@ class MockContextEngine implements ContextEngine {
}
}
class LegacySessionKeyStrictEngine implements ContextEngine {
readonly info: ContextEngineInfo = {
id: "legacy-sessionkey-strict",
name: "Legacy SessionKey Strict Engine",
};
readonly ingestCalls: Array<Record<string, unknown>> = [];
readonly assembleCalls: Array<Record<string, unknown>> = [];
readonly compactCalls: Array<Record<string, unknown>> = [];
readonly ingestedMessages: AgentMessage[] = [];
private rejectSessionKey(params: { sessionKey?: string }): void {
if (Object.prototype.hasOwnProperty.call(params, "sessionKey")) {
throw new Error("Unrecognized key(s) in object: 'sessionKey'");
}
}
async ingest(params: {
sessionId: string;
sessionKey?: string;
message: AgentMessage;
isHeartbeat?: boolean;
}): Promise<IngestResult> {
this.ingestCalls.push({ ...params });
this.rejectSessionKey(params);
this.ingestedMessages.push(params.message);
return { ingested: true };
}
async assemble(params: {
sessionId: string;
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
}): Promise<AssembleResult> {
this.assembleCalls.push({ ...params });
this.rejectSessionKey(params);
return {
messages: params.messages,
estimatedTokens: 7,
};
}
async compact(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
tokenBudget?: number;
compactionTarget?: "budget" | "threshold";
customInstructions?: string;
runtimeContext?: Record<string, unknown>;
}): Promise<CompactResult> {
this.compactCalls.push({ ...params });
this.rejectSessionKey(params);
return {
ok: true,
compacted: true,
result: {
tokensBefore: 50,
tokensAfter: 25,
},
};
}
}
class SessionKeyRuntimeErrorEngine implements ContextEngine {
readonly info: ContextEngineInfo = {
id: "sessionkey-runtime-error",
name: "SessionKey Runtime Error Engine",
};
assembleCalls = 0;
constructor(private readonly errorMessage = "sessionKey lookup failed") {}
async ingest(_params: {
sessionId: string;
sessionKey?: string;
message: AgentMessage;
isHeartbeat?: boolean;
}): Promise<IngestResult> {
return { ingested: true };
}
async assemble(_params: {
sessionId: string;
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
}): Promise<AssembleResult> {
this.assembleCalls += 1;
throw new Error(this.errorMessage);
}
async compact(_params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
tokenBudget?: number;
compactionTarget?: "budget" | "threshold";
customInstructions?: string;
runtimeContext?: Record<string, unknown>;
}): Promise<CompactResult> {
return {
ok: true,
compacted: false,
};
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 1. Engine contract tests
// ═══════════════════════════════════════════════════════════════════════════
@ -325,6 +432,97 @@ describe("Registry tests", () => {
// 3. Default engine selection
// ═══════════════════════════════════════════════════════════════════════════
describe("Legacy sessionKey compatibility", () => {
it("memoizes legacy mode after the first strict compatibility retry", async () => {
const engineId = `legacy-sessionkey-${Date.now().toString(36)}`;
const strictEngine = new LegacySessionKeyStrictEngine();
registerContextEngine(engineId, () => strictEngine);
const engine = await resolveContextEngine(configWithSlot(engineId));
const firstAssembled = await engine.assemble({
sessionId: "s1",
sessionKey: "agent:main:test",
messages: [makeMockMessage()],
});
const compacted = await engine.compact({
sessionId: "s1",
sessionKey: "agent:main:test",
sessionFile: "/tmp/session.json",
});
expect(firstAssembled.estimatedTokens).toBe(7);
expect(compacted.compacted).toBe(true);
expect(strictEngine.assembleCalls).toHaveLength(2);
expect(strictEngine.assembleCalls[0]).toHaveProperty("sessionKey", "agent:main:test");
expect(strictEngine.assembleCalls[1]).not.toHaveProperty("sessionKey");
expect(strictEngine.compactCalls).toHaveLength(1);
expect(strictEngine.compactCalls[0]).not.toHaveProperty("sessionKey");
});
it("retries strict ingest once and ingests each message only once", async () => {
const engineId = `legacy-sessionkey-ingest-${Date.now().toString(36)}`;
const strictEngine = new LegacySessionKeyStrictEngine();
registerContextEngine(engineId, () => strictEngine);
const engine = await resolveContextEngine(configWithSlot(engineId));
const firstMessage = makeMockMessage("user", "first");
const secondMessage = makeMockMessage("assistant", "second");
await engine.ingest({
sessionId: "s1",
sessionKey: "agent:main:test",
message: firstMessage,
});
await engine.ingest({
sessionId: "s1",
sessionKey: "agent:main:test",
message: secondMessage,
});
expect(strictEngine.ingestCalls).toHaveLength(3);
expect(strictEngine.ingestCalls[0]).toHaveProperty("sessionKey", "agent:main:test");
expect(strictEngine.ingestCalls[1]).not.toHaveProperty("sessionKey");
expect(strictEngine.ingestCalls[2]).not.toHaveProperty("sessionKey");
expect(strictEngine.ingestedMessages).toEqual([firstMessage, secondMessage]);
});
it("does not retry non-compat runtime errors", async () => {
const engineId = `sessionkey-runtime-${Date.now().toString(36)}`;
const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine();
registerContextEngine(engineId, () => runtimeErrorEngine);
const engine = await resolveContextEngine(configWithSlot(engineId));
await expect(
engine.assemble({
sessionId: "s1",
sessionKey: "agent:main:test",
messages: [makeMockMessage()],
}),
).rejects.toThrow("sessionKey lookup failed");
expect(runtimeErrorEngine.assembleCalls).toBe(1);
});
it("does not treat 'Unknown sessionKey' runtime failures as schema-compat errors", async () => {
const engineId = `sessionkey-unknown-runtime-${Date.now().toString(36)}`;
const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(
'Unknown sessionKey "agent:main:missing"',
);
registerContextEngine(engineId, () => runtimeErrorEngine);
const engine = await resolveContextEngine(configWithSlot(engineId));
await expect(
engine.assemble({
sessionId: "s1",
sessionKey: "agent:main:missing",
messages: [makeMockMessage()],
}),
).rejects.toThrow('Unknown sessionKey "agent:main:missing"');
expect(runtimeErrorEngine.assembleCalls).toBe(1);
});
});
describe("Default engine selection", () => {
// Ensure both legacy and a custom test engine are registered before these tests.
beforeEach(() => {

View File

@ -13,6 +13,202 @@ type RegisterContextEngineForOwnerOptions = {
allowSameOwnerRefresh?: boolean;
};
const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat");
const SESSION_KEY_COMPAT_METHODS = [
"bootstrap",
"ingest",
"ingestBatch",
"afterTurn",
"assemble",
"compact",
] as const;
type SessionKeyCompatMethodName = (typeof SESSION_KEY_COMPAT_METHODS)[number];
type SessionKeyCompatParams = {
sessionKey?: string;
};
function isSessionKeyCompatMethodName(value: PropertyKey): value is SessionKeyCompatMethodName {
return (
typeof value === "string" && (SESSION_KEY_COMPAT_METHODS as readonly string[]).includes(value)
);
}
function hasOwnSessionKey(params: unknown): params is SessionKeyCompatParams {
return (
params !== null &&
typeof params === "object" &&
Object.prototype.hasOwnProperty.call(params, "sessionKey")
);
}
function withoutSessionKey<T extends SessionKeyCompatParams>(params: T): T {
const legacyParams = { ...params };
delete legacyParams.sessionKey;
return legacyParams;
}
function issueRejectsSessionKeyStrictly(issue: unknown): boolean {
if (!issue || typeof issue !== "object") {
return false;
}
const issueRecord = issue as {
code?: unknown;
keys?: unknown;
message?: unknown;
};
if (
issueRecord.code === "unrecognized_keys" &&
Array.isArray(issueRecord.keys) &&
issueRecord.keys.some((key) => key === "sessionKey")
) {
return true;
}
return isSessionKeyCompatibilityError(issueRecord.message);
}
function* iterateErrorChain(error: unknown) {
let current = error;
const seen = new Set<unknown>();
while (current !== undefined && current !== null && !seen.has(current)) {
yield current;
seen.add(current);
if (typeof current !== "object") {
break;
}
current = (current as { cause?: unknown }).cause;
}
}
const SESSION_KEY_UNKNOWN_FIELD_PATTERNS = [
/\bunrecognized key(?:\(s\)|s)? in object:.*['"`]sessionKey['"`]/i,
/\badditional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i,
/\bmust not have additional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i,
/\b(?:unexpected|extraneous)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i,
/\b(?:unknown|invalid)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i,
/['"`]sessionKey['"`].*\b(?:was|is)\s+not allowed\b/i,
/"code"\s*:\s*"unrecognized_keys"[^]*"sessionKey"/i,
] as const;
function isSessionKeyUnknownFieldValidationMessage(message: string): boolean {
return SESSION_KEY_UNKNOWN_FIELD_PATTERNS.some((pattern) => pattern.test(message));
}
function isSessionKeyCompatibilityError(error: unknown): boolean {
for (const candidate of iterateErrorChain(error)) {
if (Array.isArray(candidate)) {
if (candidate.some((entry) => issueRejectsSessionKeyStrictly(entry))) {
return true;
}
continue;
}
if (typeof candidate === "string") {
if (isSessionKeyUnknownFieldValidationMessage(candidate)) {
return true;
}
continue;
}
if (!candidate || typeof candidate !== "object") {
continue;
}
const issueContainer = candidate as {
message?: unknown;
issues?: unknown;
errors?: unknown;
};
if (
Array.isArray(issueContainer.issues) &&
issueContainer.issues.some((issue) => issueRejectsSessionKeyStrictly(issue))
) {
return true;
}
if (
Array.isArray(issueContainer.errors) &&
issueContainer.errors.some((issue) => issueRejectsSessionKeyStrictly(issue))
) {
return true;
}
if (
typeof issueContainer.message === "string" &&
isSessionKeyUnknownFieldValidationMessage(issueContainer.message)
) {
return true;
}
}
return false;
}
async function invokeWithLegacySessionKeyCompat<TResult, TParams extends SessionKeyCompatParams>(
method: (params: TParams) => Promise<TResult> | TResult,
params: TParams,
opts?: {
onLegacyModeDetected?: () => void;
},
): Promise<TResult> {
if (!hasOwnSessionKey(params)) {
return await method(params);
}
try {
return await method(params);
} catch (error) {
if (!isSessionKeyCompatibilityError(error)) {
throw error;
}
opts?.onLegacyModeDetected?.();
return await method(withoutSessionKey(params));
}
}
function wrapContextEngineWithSessionKeyCompat(engine: ContextEngine): ContextEngine {
const marked = engine as ContextEngine & {
[LEGACY_SESSION_KEY_COMPAT]?: boolean;
};
if (marked[LEGACY_SESSION_KEY_COMPAT]) {
return engine;
}
let isLegacy = false;
const proxy: ContextEngine = new Proxy(engine, {
get(target, property, receiver) {
if (property === LEGACY_SESSION_KEY_COMPAT) {
return true;
}
const value = Reflect.get(target, property, receiver);
if (typeof value !== "function") {
return value;
}
if (!isSessionKeyCompatMethodName(property)) {
return value.bind(target);
}
return (params: SessionKeyCompatParams) => {
const method = value.bind(target) as (params: SessionKeyCompatParams) => unknown;
if (isLegacy && hasOwnSessionKey(params)) {
return method(withoutSessionKey(params));
}
return invokeWithLegacySessionKeyCompat(method, params, {
onLegacyModeDetected: () => {
isLegacy = true;
},
});
};
},
});
return proxy;
}
// ---------------------------------------------------------------------------
// Registry (module-level singleton)
// ---------------------------------------------------------------------------
@ -139,5 +335,5 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
);
}
return entry.factory();
return wrapContextEngineWithSessionKeyCompat(await entry.factory());
}