From 094a0cc4126574fb0aab2bffeb28a0c512f5045e Mon Sep 17 00:00:00 2001 From: F_ool <112874572+hhhhao28@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:14:14 +0800 Subject: [PATCH] fix(context-engine): preserve legacy plugin sessionKey interop (#44779) Merged via squash. Prepared head SHA: e04c6fb47d1ad2623121c907b2e8dcaff62b9ad7 Co-authored-by: hhhhao28 <112874572+hhhhao28@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/context-engine/context-engine.test.ts | 198 ++++++++++++++++++++++ src/context-engine/registry.ts | 198 +++++++++++++++++++++- 3 files changed, 396 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c30c8dad708..7a4850dc72d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 703ee88bf57..f684e5596d5 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -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> = []; + readonly assembleCalls: Array> = []; + readonly compactCalls: Array> = []; + 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 { + 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 { + 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; + }): Promise { + 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 { + return { ingested: true }; + } + + async assemble(_params: { + sessionId: string; + sessionKey?: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise { + 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; + }): Promise { + 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(() => { diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 1701877790a..2c5cac439c0 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -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(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(); + 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( + method: (params: TParams) => Promise | TResult, + params: TParams, + opts?: { + onLegacyModeDetected?: () => void; + }, +): Promise { + 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