diff --git a/extensions/qa-lab/src/providers/mock-openai/mock-openai-directives.ts b/extensions/qa-lab/src/providers/mock-openai/mock-openai-directives.ts
index 77c466bc723f..96fac4e7771b 100644
--- a/extensions/qa-lab/src/providers/mock-openai/mock-openai-directives.ts
+++ b/extensions/qa-lab/src/providers/mock-openai/mock-openai-directives.ts
@@ -348,7 +348,9 @@ export function isHeartbeatPrompt(text: string) {
if (!trimmed || /remember this fact/i.test(trimmed)) {
return false;
}
- return /(?:^|\n)Read HEARTBEAT\.md if it exists\b/i.test(trimmed);
+ return /(?:^|\n)(?:Read HEARTBEAT\.md if it exists|Follow the heartbeat monitor scratch context when provided)\b/i.test(
+ trimmed,
+ );
}
export function readFirstMediaPath(value: unknown): string {
diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts
index 0510da7b65a5..805c383616f9 100644
--- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts
+++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts
@@ -4883,6 +4883,33 @@ Update and merge these partial structured summaries.`,
expect(outputText(await response.json())).toBe("HEARTBEAT_OK");
});
+ it("answers current heartbeat prompts without replaying earlier image requests", async () => {
+ const server = await startMockServer();
+ const imageRequest =
+ "Capability flip image check: generate a QA lighthouse image in this turn right now.";
+ const heartbeatRequest = [
+ "OpenClaw assembled context for this turn:",
+ "",
+ "[user]",
+ imageRequest,
+ "",
+ "",
+ "Current user request:",
+ "System: Gateway restart required (config.patch)",
+ "",
+ "Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
+ ].join("\n");
+
+ const response = await expectNonStreamingResponses(server, {
+ tools: [IMAGE_GENERATE_TOOL],
+ input: [makeUserInput(imageRequest), makeUserInput(heartbeatRequest)],
+ });
+ const payload = await response.json();
+
+ expect(outputItem(payload)).toMatchObject({ type: "message" });
+ expect(outputText(payload)).toBe("HEARTBEAT_OK");
+ });
+
it("returns exact markers for visible and hot-installed skills", async () => {
const server = await startMockServer();
diff --git a/src/gateway/server-methods/config.test.ts b/src/gateway/server-methods/config.test.ts
index ba743cf7cbe1..e496c1f98591 100644
--- a/src/gateway/server-methods/config.test.ts
+++ b/src/gateway/server-methods/config.test.ts
@@ -494,6 +494,65 @@ describe("config.patch hash-free ui.prefs LWW", () => {
});
describe("config.patch ID-keyed arrays", () => {
+ it.each([false, true])(
+ "keeps catalog-only model defaults out of authored patches (authored compat: %s)",
+ async (authoredCompat) => {
+ const authoredTextModel = {
+ id: "gpt-5.5",
+ ...(authoredCompat ? { compat: { supportsStore: false } } : {}),
+ };
+ const sourceConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ models: [authoredTextModel, { id: "gpt-image-1" }],
+ },
+ },
+ },
+ } as unknown as OpenClawConfig;
+ const runtimeConfig = structuredClone(sourceConfig);
+ const runtimeModel = runtimeConfig.models?.providers?.openai?.models[0];
+ expect(runtimeModel).toBeDefined();
+ Object.assign(runtimeModel!, {
+ compat: authoredCompat ? { supportsStore: false } : { codeMode: "preferred" },
+ contextTokens: 272_000,
+ });
+ storedConfig = sourceConfig;
+ configWriteMocks.readConfigFileSnapshotForWrite.mockImplementationOnce(async () => {
+ const snapshot = createConfigWriteSnapshot(runtimeConfig);
+ snapshot.snapshot.sourceConfig = sourceConfig;
+ snapshot.snapshot.resolved = sourceConfig;
+ snapshot.snapshot.parsed = sourceConfig;
+ snapshot.snapshot.raw = JSON.stringify(sourceConfig);
+ return snapshot;
+ });
+
+ const { respond } = await invokeConfigPatch({
+ raw: {
+ models: {
+ providers: {
+ openai: {
+ models: [{ id: "gpt-image-1", baseUrl: "http://127.0.0.1:44080/v1" }],
+ },
+ },
+ },
+ },
+ baseHash: "base-hash",
+ });
+
+ expect(respond).toHaveBeenCalledWith(
+ true,
+ expect.objectContaining({ ok: true, hash: "next-hash-1" }),
+ undefined,
+ );
+ expect(storedConfig.models?.providers?.openai?.models).toEqual([
+ authoredTextModel,
+ { id: "gpt-image-1", baseUrl: "http://127.0.0.1:44080/v1" },
+ ]);
+ },
+ );
+
it("rejects duplicate IDs before applying an ID-merged array patch", async () => {
storedConfig = {
models: {
diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts
index 394e68054166..a0d93cbf2cd2 100644
--- a/src/gateway/server-methods/config.ts
+++ b/src/gateway/server-methods/config.ts
@@ -1050,11 +1050,16 @@ export const configHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatErrorMessage(error)));
return;
}
- const merged = applyMergePatch(snapshot.config, normalizedPatch, {
+ // Source-owned array rows must not persist runtime-only catalog defaults from siblings.
+ const mergedSource = applyMergePatch(snapshot.resolved, normalizedPatch, {
// Arrays with stable ids behave like maps for partial control-plane edits.
mergeObjectArraysById: true,
replaceArrayPaths: replacePaths,
});
+ const merged = applyMergePatch(
+ snapshot.config,
+ createMergePatch(snapshot.resolved, mergedSource),
+ );
const schemaPatch = loadSchemaWithPlugins();
const restoredMerge = restoreRedactedValues(merged, snapshot.config, schemaPatch.uiHints);
if (!restoredMerge.ok) {
diff --git a/src/plugins/loader-cache.ts b/src/plugins/loader-cache.ts
index 802664a9c1d1..fcfa5f4bbf57 100644
--- a/src/plugins/loader-cache.ts
+++ b/src/plugins/loader-cache.ts
@@ -1,3 +1,4 @@
+import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { PluginLoaderCacheState } from "./loader-cache-state.js";
import { resolvePluginLoadCacheContext } from "./loader-load-context.js";
import type { PluginLoadOptions } from "./loader-types.js";
@@ -6,8 +7,11 @@ import type { PluginRegistry } from "./registry-types.js";
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128;
-export const pluginLoaderCacheState = new PluginLoaderCacheState(
- MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,
+export const pluginLoaderCacheState = resolveGlobalSingleton(
+ Symbol.for("openclaw.plugins.loader-cache-state"),
+ () => new PluginLoaderCacheState(MAX_PLUGIN_REGISTRY_CACHE_ENTRIES),
+ (cache) => cache.clearCachedRegistries(),
+ "plugin-registry",
);
export function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void {
diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts
index 918eef17c9bf..de7dc460207f 100644
--- a/src/plugins/loader.runtime-registry.test.ts
+++ b/src/plugins/loader.runtime-registry.test.ts
@@ -12,6 +12,7 @@ import {
loadInstalledPluginIndexInstallRecordsSync,
writePersistedInstalledPluginIndexInstallRecordsSync,
} from "./installed-plugin-index-records.js";
+import { getReusableCachedPluginRegistry, setCachedPluginRegistry } from "./loader-cache.js";
import { resolvePluginLoadCacheContext } from "./loader-load-context.js";
import { createLazyPluginRuntime } from "./loader-module-runtime.js";
import {
@@ -30,7 +31,11 @@ import {
import { buildMemoryPromptSection, registerMemoryCapability } from "./memory-state.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import { createEmptyPluginRegistry } from "./registry.js";
-import { getActivePluginRegistry, setActivePluginRegistry } from "./runtime.js";
+import {
+ clearActivePluginRegistry,
+ getActivePluginRegistry,
+ setActivePluginRegistry,
+} from "./runtime.js";
import type { PluginRuntime } from "./runtime/types.js";
afterEach(() => {
@@ -119,6 +124,22 @@ describe("cached plugin load failures", () => {
});
});
+it("invalidates cached registries when the active plugin registry lifecycle closes", async () => {
+ const rootCacheKey = "restart-root";
+ const snapshotCacheKey = "restart-gateway-bound-snapshot";
+ const root = createEmptyPluginRegistry();
+ const snapshot = createEmptyPluginRegistry();
+ setCachedPluginRegistry(rootCacheKey, root);
+ setCachedPluginRegistry(snapshotCacheKey, snapshot);
+ setActivePluginRegistry(root, rootCacheKey, "gateway-bindable");
+
+ await clearActivePluginRegistry();
+
+ expect(getReusableCachedPluginRegistry(rootCacheKey)).toBeUndefined();
+ expect(getReusableCachedPluginRegistry(snapshotCacheKey)).toBeUndefined();
+ expect(getActivePluginRegistry()).toBeNull();
+});
+
function requireMemoryEmbeddingProvider(providerId: string) {
const provider = getRegisteredEmbeddingProvider(providerId)?.adapter;
if (!provider) {