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) {