mirror of https://github.com/openclaw/openclaw.git
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:
parent
ebee4e2210
commit
094a0cc412
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue