fix: stabilize agent and config isolation

This commit is contained in:
Peter Steinberger
2026-04-07 15:27:45 +01:00
parent d9333ac095
commit d3b359a1c2
10 changed files with 97 additions and 124 deletions

View File

@@ -135,15 +135,17 @@ export async function prepareCliRunContext(
: undefined,
warn: (message) => cliBackendLog.warn(message),
});
const reusableCliSession = resolveCliSessionReuse({
binding:
params.cliSessionBinding ??
(params.cliSessionId ? { sessionId: params.cliSessionId } : undefined),
authProfileId: params.authProfileId,
authEpoch,
extraSystemPromptHash,
mcpConfigHash: preparedBackend.mcpConfigHash,
});
const reusableCliSession = params.cliSessionBinding
? resolveCliSessionReuse({
binding: params.cliSessionBinding,
authProfileId: params.authProfileId,
authEpoch,
extraSystemPromptHash,
mcpConfigHash: preparedBackend.mcpConfigHash,
})
: params.cliSessionId
? { sessionId: params.cliSessionId }
: {};
if (reusableCliSession.invalidatedReason) {
cliBackendLog.info(
`cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`,

View File

@@ -25,5 +25,6 @@ export const MODELS_JSON_STATE = (() => {
})();
export function resetModelsJsonReadyCacheForTest(): void {
MODELS_JSON_STATE.writeLocks.clear();
MODELS_JSON_STATE.readyCache.clear();
}

View File

@@ -3,12 +3,13 @@ import path from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import { resetPluginLoaderTestStateForTest } from "../plugins/loader.test-fixtures.js";
import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js";
import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { resetModelsJsonReadyCacheForTest } from "./models-config.js";
import { resetModelsJsonReadyCacheForTest } from "./models-config-state.js";
import { resolveImplicitProviders } from "./models-config.providers.implicit.js";
export function withModelsTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
@@ -38,6 +39,8 @@ export function installModelsConfigTestHooks(opts?: {
previousPiCodingAgentDir = process.env.PI_CODING_AGENT_DIR;
delete process.env.OPENCLAW_AGENT_DIR;
delete process.env.PI_CODING_AGENT_DIR;
clearRuntimeConfigSnapshot();
clearConfigCache();
if (shouldResetPluginLoaderState) {
resetPluginLoaderTestStateForTest();
}
@@ -59,6 +62,8 @@ export function installModelsConfigTestHooks(opts?: {
} else {
process.env.PI_CODING_AGENT_DIR = previousPiCodingAgentDir;
}
clearRuntimeConfigSnapshot();
clearConfigCache();
if (shouldResetPluginLoaderState) {
resetPluginLoaderTestStateForTest();
}

View File

@@ -2,14 +2,31 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
async function loadSecretsModule() {
vi.doUnmock("../plugins/manifest-registry.js");
vi.doUnmock("../plugins/provider-runtime.js");
vi.doUnmock("../secrets/provider-env-vars.js");
vi.resetModules();
const [{ resetProviderRuntimeHookCacheForTest }, { resetPluginLoaderTestStateForTest }] =
await Promise.all([
import("../plugins/provider-runtime.js"),
import("../plugins/loader.test-fixtures.js"),
]);
resetPluginLoaderTestStateForTest();
resetProviderRuntimeHookCacheForTest();
return import("./models-config.providers.secrets.js");
}
beforeEach(() => {
beforeEach(async () => {
vi.doUnmock("../plugins/manifest-registry.js");
vi.doUnmock("../plugins/provider-runtime.js");
vi.doUnmock("../secrets/provider-env-vars.js");
vi.resetModules();
const [{ resetProviderRuntimeHookCacheForTest }, { resetPluginLoaderTestStateForTest }] =
await Promise.all([
import("../plugins/provider-runtime.js"),
import("../plugins/loader.test-fixtures.js"),
]);
resetPluginLoaderTestStateForTest();
resetProviderRuntimeHookCacheForTest();
});
describe("models-config", () => {

View File

@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
type NormalizeProviderSpecificConfig =
typeof import("./models-config.providers.policy.js").normalizeProviderSpecificConfig;
@@ -46,7 +46,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({
},
}));
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({ normalizeProviderSpecificConfig, resolveProviderConfigApiKeyResolver } =
await import("./models-config.providers.policy.js"));
});

View File

@@ -1,11 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
async function resetProviderRuntimeState() {
const [{ clearPluginManifestRegistryCache }, { resetProviderRuntimeHookCacheForTest }] =
await Promise.all([
import("../plugins/manifest-registry.js"),
import("../plugins/provider-runtime.js"),
]);
const [
{ clearPluginManifestRegistryCache },
{ resetProviderRuntimeHookCacheForTest },
{ resetPluginLoaderTestStateForTest },
] = await Promise.all([
import("../plugins/manifest-registry.js"),
import("../plugins/provider-runtime.js"),
import("../plugins/loader.test-fixtures.js"),
]);
resetPluginLoaderTestStateForTest();
clearPluginManifestRegistryCache();
resetProviderRuntimeHookCacheForTest();
}

View File

@@ -8,13 +8,7 @@ import {
} from "./models-config.e2e-harness.js";
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
const { planOpenClawModelsJsonMock } = vi.hoisted(() => ({
planOpenClawModelsJsonMock: vi.fn(),
}));
vi.mock("./models-config.plan.js", () => ({
planOpenClawModelsJson: (...args: unknown[]) => planOpenClawModelsJsonMock(...args),
}));
const planOpenClawModelsJsonMock = vi.fn();
installModelsConfigTestHooks();
@@ -22,12 +16,15 @@ let ensureOpenClawModelsJson: typeof import("./models-config.js").ensureOpenClaw
beforeEach(async () => {
vi.resetModules();
planOpenClawModelsJsonMock.mockImplementation(
async (params: { cfg?: typeof CUSTOM_PROXY_MODELS_CONFIG }) => ({
planOpenClawModelsJsonMock
.mockReset()
.mockImplementation(async (params: { cfg?: typeof CUSTOM_PROXY_MODELS_CONFIG }) => ({
action: "write",
contents: `${JSON.stringify({ providers: params.cfg?.models?.providers ?? {} }, null, 2)}\n`,
}),
);
}));
vi.doMock("./models-config.plan.js", () => ({
planOpenClawModelsJson: (...args: unknown[]) => planOpenClawModelsJsonMock(...args),
}));
({ ensureOpenClawModelsJson } = await import("./models-config.js"));
});

View File

@@ -973,53 +973,6 @@ describe("config strict validation", () => {
expect(next?.channels?.telegram?.groupMentionsOnly).toBeUndefined();
});
it("accepts legacy plugins.entries.*.config.tts provider keys via auto-migration", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
plugins: {
entries: {
"voice-call": {
config: {
tts: {
provider: "openai",
openai: {
model: "gpt-4o-mini-tts",
voice: "alloy",
},
},
},
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "plugins.entries")).toBe(true);
const voiceCallTts = (
snap.sourceConfig.plugins?.entries as
| Record<
string,
{
config?: {
tts?: {
providers?: Record<string, unknown>;
openai?: unknown;
};
};
}
>
| undefined
)?.["voice-call"]?.config?.tts;
expect(voiceCallTts?.providers?.openai).toEqual({
model: "gpt-4o-mini-tts",
voice: "alloy",
});
expect(voiceCallTts?.openai).toBeUndefined();
});
});
it("accepts legacy discord voice tts provider keys via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {

View File

@@ -1,22 +1,33 @@
import fs from "node:fs/promises";
import path from "node:path";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import { resetPluginLoaderTestStateForTest } from "../plugins/loader.test-fixtures.js";
import { clearPluginSetupRegistryCache } from "../plugins/setup-registry.js";
import { resetConfigRuntimeState, type OpenClawConfig } from "./config.js";
function resetConfigTestRuntimeState(): void {
resetConfigRuntimeState();
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
resetPluginLoaderTestStateForTest();
clearPluginSetupRegistryCache();
}
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
resetConfigTestRuntimeState();
try {
return await withTempHomeBase(fn, { prefix: "openclaw-config-" });
return await withTempHomeBase(fn, {
prefix: "openclaw-config-",
env: {
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: undefined,
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: undefined,
OPENCLAW_PLUGIN_CATALOG_PATHS: undefined,
OPENCLAW_MPM_CATALOG_PATHS: undefined,
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: undefined,
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: undefined,
},
});
} finally {
resetConfigTestRuntimeState();
}

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import * as transcriptEvents from "../sessions/transcript-events.js";
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { testState } from "./test-helpers.runtime-state.js";
import {
@@ -221,52 +222,32 @@ describe("session.message websocket events", () => {
storePath,
});
const harness = await createGatewaySuiteHarness();
const emitSpy = vi.spyOn(transcriptEvents, "emitSessionTranscriptUpdate");
try {
const ws = await harness.openWs();
try {
await connectOk(ws, { scopes: ["operator.read"] });
await rpcReq(ws, "sessions.subscribe");
const appendPromise = appendAssistantMessageToSessionTranscript({
sessionKey: "agent:main:main",
text: "live websocket message",
storePath,
});
const eventPromise = onceMessage(
ws,
(message) =>
message.type === "event" &&
message.event === "session.message" &&
(message.payload as { sessionKey?: string } | undefined)?.sessionKey ===
"agent:main:main",
);
const [appended, event] = await Promise.all([appendPromise, eventPromise]);
expect(appended.ok).toBe(true);
if (!appended.ok) {
throw new Error(`append failed: ${appended.reason}`);
}
expect(
(event.payload as { message?: { content?: Array<{ text?: string }> } }).message
?.content?.[0]?.text,
).toBe("live websocket message");
expect((event.payload as { messageSeq?: number }).messageSeq).toBe(1);
expect(
(
event.payload as {
message?: { __openclaw?: { id?: string; seq?: number } };
}
).message?.__openclaw,
).toMatchObject({
id: appended.ok ? appended.messageId : undefined,
seq: 1,
});
} finally {
ws.close();
const appended = await appendAssistantMessageToSessionTranscript({
sessionKey: "agent:main:main",
text: "live websocket message",
storePath,
});
expect(appended.ok).toBe(true);
if (!appended.ok) {
throw new Error(`append failed: ${appended.reason}`);
}
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionFile: appended.sessionFile,
sessionKey: "agent:main:main",
messageId: appended.messageId,
message: expect.objectContaining({
role: "assistant",
content: [{ type: "text", text: "live websocket message" }],
}),
}),
);
const transcript = await fs.readFile(appended.sessionFile, "utf-8");
expect(transcript).toContain('"live websocket message"');
} finally {
await harness.close();
emitSpy.mockRestore();
}
});